diff --git a/app/assets/js/room-polygon-editor.js b/app/assets/js/room-polygon-editor.js new file mode 100644 index 0000000..123cb71 --- /dev/null +++ b/app/assets/js/room-polygon-editor.js @@ -0,0 +1,377 @@ +(() => { + const SVG_NS = 'http://www.w3.org/2000/svg'; + const DEFAULT_VIEWBOX = { x: 0, y: 0, width: 2000, height: 1000 }; + const SNAP_TOLERANCE = 16; + + function initRoomPolygonEditor() { + const floorSelect = document.getElementById('room-floor-id'); + const canvas = document.getElementById('room-polygon-canvas'); + const polygonInput = document.getElementById('room-polygon-points'); + const snapWalls = document.getElementById('room-snap-walls'); + const undoButton = document.getElementById('room-undo-point'); + const clearButton = document.getElementById('room-clear-polygon'); + const mapHint = document.getElementById('room-map-hint'); + + if (!floorSelect || !canvas || !polygonInput) { + return; + } + + const state = { + viewBox: { ...DEFAULT_VIEWBOX }, + wallPoints: [], + floorLayerNodes: [], + points: parsePoints(polygonInput.value), + draggingIndex: null + }; + + const updateInput = () => { + if (state.points.length >= 3) { + polygonInput.value = JSON.stringify(state.points.map((point) => ({ + x: Math.round(point.x), + y: Math.round(point.y) + }))); + } else { + polygonInput.value = ''; + } + }; + + const render = () => { + canvas.innerHTML = ''; + canvas.setAttribute( + 'viewBox', + `${state.viewBox.x} ${state.viewBox.y} ${state.viewBox.width} ${state.viewBox.height}` + ); + + const bg = createSvgElement('rect'); + bg.setAttribute('x', String(state.viewBox.x)); + bg.setAttribute('y', String(state.viewBox.y)); + bg.setAttribute('width', String(state.viewBox.width)); + bg.setAttribute('height', String(state.viewBox.height)); + bg.setAttribute('fill', '#f7f7f7'); + bg.setAttribute('stroke', '#e1e1e1'); + canvas.appendChild(bg); + + if (state.floorLayerNodes.length > 0) { + const floorLayer = createSvgElement('g'); + floorLayer.setAttribute('opacity', '0.55'); + floorLayer.setAttribute('pointer-events', 'none'); + state.floorLayerNodes.forEach((node) => { + floorLayer.appendChild(node.cloneNode(true)); + }); + canvas.appendChild(floorLayer); + } + + if (state.points.length >= 2) { + const polyline = createSvgElement('polyline'); + polyline.setAttribute('fill', state.points.length >= 3 ? 'rgba(13, 110, 253, 0.16)' : 'none'); + polyline.setAttribute('stroke', '#0d6efd'); + polyline.setAttribute('stroke-width', '3'); + polyline.setAttribute('points', buildPointString(state.points)); + if (state.points.length >= 3) { + polyline.setAttribute('stroke-linejoin', 'round'); + polyline.setAttribute('stroke-linecap', 'round'); + polyline.setAttribute('points', `${buildPointString(state.points)} ${state.points[0].x},${state.points[0].y}`); + } + canvas.appendChild(polyline); + } + + state.points.forEach((point, index) => { + const vertex = createSvgElement('circle'); + vertex.setAttribute('cx', String(point.x)); + vertex.setAttribute('cy', String(point.y)); + vertex.setAttribute('r', '9'); + vertex.setAttribute('fill', '#ffffff'); + vertex.setAttribute('stroke', '#dc3545'); + vertex.setAttribute('stroke-width', '3'); + vertex.setAttribute('data-vertex-index', String(index)); + canvas.appendChild(vertex); + }); + + updateInput(); + }; + + const snapPoint = (point) => { + if (!snapWalls || !snapWalls.checked || state.wallPoints.length === 0) { + return point; + } + let nearest = null; + let nearestDist = Number.POSITIVE_INFINITY; + state.wallPoints.forEach((wallPoint) => { + const dx = wallPoint.x - point.x; + const dy = wallPoint.y - point.y; + const dist = Math.sqrt((dx * dx) + (dy * dy)); + if (dist < nearestDist) { + nearestDist = dist; + nearest = wallPoint; + } + }); + if (nearest && nearestDist <= SNAP_TOLERANCE) { + return { x: nearest.x, y: nearest.y }; + } + return point; + }; + + const addPoint = (event) => { + const point = toSvgPoint(canvas, event); + if (!point) { + return; + } + state.points.push(snapPoint(point)); + render(); + }; + + const onPointerDown = (event) => { + const target = event.target; + if (!(target instanceof SVGElement)) { + return; + } + const vertex = target.closest('[data-vertex-index]'); + if (vertex) { + const vertexIndex = Number(vertex.getAttribute('data-vertex-index')); + if (Number.isInteger(vertexIndex)) { + state.draggingIndex = vertexIndex; + vertex.setPointerCapture(event.pointerId); + } + return; + } + addPoint(event); + }; + + const onPointerMove = (event) => { + if (state.draggingIndex === null) { + return; + } + const point = toSvgPoint(canvas, event); + if (!point) { + return; + } + state.points[state.draggingIndex] = snapPoint(point); + render(); + }; + + const stopDragging = () => { + state.draggingIndex = null; + }; + + const parseFloorSvg = (rawSvg) => { + const parser = new DOMParser(); + const doc = parser.parseFromString(rawSvg, 'image/svg+xml'); + const root = doc.documentElement; + if (!root || root.nodeName.toLowerCase() === 'parsererror') { + return null; + } + return root; + }; + + const readViewBox = (svgRoot) => { + const vb = (svgRoot.getAttribute('viewBox') || '').trim(); + if (vb) { + const parts = vb.split(/\s+/).map((value) => Number(value)); + if (parts.length === 4 && parts.every((value) => Number.isFinite(value))) { + 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 collectSnapPoints = (svgRoot) => { + const points = []; + + const addPointValue = (x, y) => { + if (!Number.isFinite(x) || !Number.isFinite(y)) { + return; + } + points.push({ x: Math.round(x), y: Math.round(y) }); + }; + + svgRoot.querySelectorAll('line').forEach((line) => { + addPointValue(Number(line.getAttribute('x1')), Number(line.getAttribute('y1'))); + addPointValue(Number(line.getAttribute('x2')), Number(line.getAttribute('y2'))); + }); + + svgRoot.querySelectorAll('polyline, polygon').forEach((shape) => { + parsePointString(shape.getAttribute('points') || '').forEach((point) => addPointValue(point.x, point.y)); + }); + + svgRoot.querySelectorAll('rect').forEach((rect) => { + const x = Number(rect.getAttribute('x')); + const y = Number(rect.getAttribute('y')); + const width = Number(rect.getAttribute('width')); + const height = Number(rect.getAttribute('height')); + addPointValue(x, y); + addPointValue(x + width, y); + addPointValue(x + width, y + height); + addPointValue(x, y + height); + }); + + svgRoot.querySelectorAll('path').forEach((path) => { + const d = path.getAttribute('d') || ''; + const numbers = (d.match(/-?\d+(\.\d+)?/g) || []).map((value) => Number(value)); + for (let i = 0; i < numbers.length - 1; i += 2) { + addPointValue(numbers[i], numbers[i + 1]); + } + }); + + return dedupePoints(points); + }; + + const loadFloor = async () => { + const selected = floorSelect.selectedOptions[0]; + const svgUrl = selected ? (selected.dataset.svgUrl || '') : ''; + + state.viewBox = { ...DEFAULT_VIEWBOX }; + state.wallPoints = []; + state.floorLayerNodes = []; + + if (!svgUrl) { + if (mapHint) { + mapHint.textContent = 'Fuer dieses Stockwerk ist keine Karte hinterlegt. Polygon kann trotzdem frei gezeichnet werden.'; + } + render(); + return; + } + + try { + const response = await fetch(svgUrl, { credentials: 'same-origin' }); + if (!response.ok) { + throw new Error('SVG not available'); + } + const raw = await response.text(); + const root = parseFloorSvg(raw); + if (!root) { + throw new Error('Invalid SVG'); + } + + state.viewBox = readViewBox(root); + state.wallPoints = collectSnapPoints(root); + state.floorLayerNodes = Array.from(root.childNodes) + .filter((node) => node.nodeType === Node.ELEMENT_NODE) + .map((node) => node.cloneNode(true)); + + if (mapHint) { + mapHint.textContent = state.wallPoints.length > 0 + ? 'Klick setzt Punkte. Punkte sind per Drag verschiebbar. Snap nutzt Wandpunkte aus der Stockwerkskarte.' + : 'Klick setzt Punkte. Punkte sind per Drag verschiebbar.'; + } + } catch (error) { + if (mapHint) { + mapHint.textContent = 'Stockwerkskarte konnte nicht geladen werden. Polygon kann frei gezeichnet werden.'; + } + } + + render(); + }; + + canvas.addEventListener('pointerdown', onPointerDown); + canvas.addEventListener('pointermove', onPointerMove); + canvas.addEventListener('pointerup', stopDragging); + canvas.addEventListener('pointercancel', stopDragging); + canvas.addEventListener('pointerleave', stopDragging); + + floorSelect.addEventListener('change', () => { + loadFloor(); + }); + + if (undoButton) { + undoButton.addEventListener('click', () => { + if (state.points.length === 0) { + return; + } + state.points.pop(); + render(); + }); + } + + if (clearButton) { + clearButton.addEventListener('click', () => { + state.points = []; + render(); + }); + } + + render(); + loadFloor(); + } + + function parsePoints(raw) { + if (!raw) { + return []; + } + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return []; + } + return parsed + .map((point) => ({ + x: Number(point && point.x), + y: Number(point && point.y) + })) + .filter((point) => Number.isFinite(point.x) && Number.isFinite(point.y)); + } catch (error) { + return []; + } + } + + function parsePointString(pointsAttr) { + return pointsAttr + .trim() + .split(/\s+/) + .map((pair) => { + const values = pair.split(','); + if (values.length !== 2) { + return null; + } + const x = Number(values[0]); + const y = Number(values[1]); + if (!Number.isFinite(x) || !Number.isFinite(y)) { + return null; + } + return { x, y }; + }) + .filter(Boolean); + } + + function dedupePoints(points) { + const map = new Map(); + points.forEach((point) => { + const key = `${Math.round(point.x)}:${Math.round(point.y)}`; + if (!map.has(key)) { + map.set(key, { x: Math.round(point.x), y: Math.round(point.y) }); + } + }); + return Array.from(map.values()); + } + + function buildPointString(points) { + return points.map((point) => `${Math.round(point.x)},${Math.round(point.y)}`).join(' '); + } + + function createSvgElement(name) { + return document.createElementNS(SVG_NS, name); + } + + function toSvgPoint(svg, event) { + const pt = svg.createSVGPoint(); + pt.x = event.clientX; + pt.y = event.clientY; + const ctm = svg.getScreenCTM(); + if (!ctm) { + return null; + } + const transformed = pt.matrixTransform(ctm.inverse()); + return { x: transformed.x, y: transformed.y }; + } + + document.addEventListener('DOMContentLoaded', initRoomPolygonEditor); +})(); diff --git a/app/index.php b/app/index.php index a76aab9..c7fc06d 100644 --- a/app/index.php +++ b/app/index.php @@ -27,7 +27,7 @@ $module = $_GET['module'] ?? 'dashboard'; $action = $_GET['action'] ?? 'list'; // Whitelist der Module -$validModules = ['dashboard', 'locations', 'buildings', 'device_types', 'devices', 'racks', 'floors', 'floor_infrastructure', 'connections', 'port_types']; +$validModules = ['dashboard', 'locations', 'buildings', 'rooms', 'device_types', 'devices', 'racks', 'floors', 'floor_infrastructure', 'connections', 'port_types']; // Whitelist der Aktionen $validActions = ['list', 'edit', 'save', 'ports', 'delete']; diff --git a/app/modules/locations/list.php b/app/modules/locations/list.php index be0aea0..0283425 100644 --- a/app/modules/locations/list.php +++ b/app/modules/locations/list.php @@ -1,25 +1,19 @@ -get( "SELECT b.id, b.location_id, b.name, b.comment FROM buildings b ORDER BY b.location_id, b.name", - "", + '', + [] +); + +$floors = $sql->get( + "SELECT f.id, f.building_id, f.name, f.level, + COUNT(r.id) AS room_count + FROM floors f + LEFT JOIN rooms r ON r.floor_id = f.id + GROUP BY f.id + ORDER BY f.building_id, f.level, f.name", + '', + [] +); + +$rooms = $sql->get( + "SELECT r.id, r.floor_id, r.name, r.number, r.comment, + COUNT(no.id) AS outlet_count + FROM rooms r + LEFT JOIN network_outlets no ON no.room_id = r.id + GROUP BY r.id + ORDER BY r.floor_id, r.name", + '', [] ); $buildingsByLocation = []; foreach ($buildings as $building) { - $buildingsByLocation[$building['location_id']][] = $building; + $buildingsByLocation[(int)$building['location_id']][] = $building; } -$floors = $sql->get( - "SELECT f.id, f.building_id, f.name, f.level - FROM floors f - ORDER BY f.building_id, f.level", - "", - [] -); - $floorsByBuilding = []; foreach ($floors as $floor) { - $floorsByBuilding[$floor['building_id']][] = $floor; + $floorsByBuilding[(int)$floor['building_id']][] = $floor; } +$roomsByFloor = []; +foreach ($rooms as $room) { + $roomsByFloor[(int)$room['floor_id']][] = $room; +} ?>

Standorte

-
- @@ -82,15 +91,12 @@ foreach ($floors as $floor) {
- - + @@ -98,125 +104,147 @@ foreach ($floors as $floor) { - - - - - - + + +
NameGebäudeGebaeude Beschreibung Aktionen
- - - - - - - Bearbeiten - Löschen + Bearbeiten + Loeschen
-

Keine Standorte gefunden.

-

- - Ersten Standort anlegen - -

+

Ersten Standort anlegen

-

Gebäude & Stockwerke nach Standorten

+

Gebaeude, Stockwerke und Raeume

+ - - - - - - - - +
StandortGebäudeStockwerkDetailsAktionen
+ + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + - - - - + + + - - - -
StandortGebaeudeStockwerk / RaumDetailsAktionen
+ + ( Gebaeude) + + + Gebaeude +
- - ( Gebäude) - - + Gebäude -
  - - - - Kein Kommentar - - - Bearbeiten - Löschen - + Stockwerk -
   - - Ebene - - Keine Ebene - - - Bearbeiten - Löschen -
  Keine GebäudeFür diesen Standort sind noch keine Gebäude vorhanden. + + + + Kein Kommentar + + + Bearbeiten + Loeschen + + Stockwerk +
+ + + + + +   +   + + + + Ebene + + Keine Ebene + + | Raeume + + + Bearbeiten + Loeschen + + Raum + + + + + + +   +   + + + + () + + + + Dosen + + | + + + + Bearbeiten + Loeschen + + + + + +   +   + Keine Raeume + Fuer dieses Stockwerk sind noch keine Raeume angelegt. + + + + + + + + +   +   + Keine Gebaeude + Fuer diesen Standort sind noch keine Gebaeude vorhanden. + + + + + +

Keine Standorte gefunden.

- - - - diff --git a/app/modules/rooms/delete.php b/app/modules/rooms/delete.php new file mode 100644 index 0000000..86e7041 --- /dev/null +++ b/app/modules/rooms/delete.php @@ -0,0 +1,64 @@ + false, + 'message' => 'Methode nicht erlaubt' + ]); + exit; +} + +$roomId = (int)($_POST['id'] ?? $_GET['id'] ?? 0); + +if ($roomId <= 0) { + http_response_code(400); + echo json_encode([ + 'success' => false, + 'message' => 'Ungueltige Raum-ID' + ]); + exit; +} + +$room = $sql->single( + "SELECT id, name FROM rooms WHERE id = ?", + "i", + [$roomId] +); + +if (!$room) { + http_response_code(404); + echo json_encode([ + 'success' => false, + 'message' => 'Raum nicht gefunden' + ]); + exit; +} + +$deleted = $sql->set( + "DELETE FROM rooms WHERE id = ?", + "i", + [$roomId] +); + +if ($deleted <= 0) { + http_response_code(500); + echo json_encode([ + 'success' => false, + 'message' => 'Raum konnte nicht geloescht werden' + ]); + exit; +} + +echo json_encode([ + 'success' => true, + 'message' => 'Raum geloescht' +]); +exit; diff --git a/app/modules/rooms/edit.php b/app/modules/rooms/edit.php new file mode 100644 index 0000000..1004cfb --- /dev/null +++ b/app/modules/rooms/edit.php @@ -0,0 +1,234 @@ + 0) { + $room = $sql->single( + "SELECT * FROM rooms WHERE id = ?", + "i", + [$roomId] + ); +} + +$isEdit = !empty($room); +$prefillFloorId = (int)($_GET['floor_id'] ?? 0); +$selectedFloorId = (int)($room['floor_id'] ?? $prefillFloorId); + +$floors = $sql->get( + "SELECT f.id, f.name, f.level, f.svg_path, b.name AS building_name, l.name AS location_name + FROM floors f + LEFT JOIN buildings b ON b.id = f.building_id + LEFT JOIN locations l ON l.id = b.location_id + ORDER BY l.name, b.name, f.level, f.name", + "", + [] +); + +foreach ($floors as &$floor) { + $svgPath = trim((string)($floor['svg_path'] ?? '')); + $floor['svg_url'] = $svgPath !== '' ? '/' . ltrim($svgPath, "/\\") : ''; +} +unset($floor); + +$existingPolygon = trim((string)($room['polygon_points'] ?? '')); +$pageTitle = $isEdit ? "Raum bearbeiten: " . htmlspecialchars((string)$room['name']) : "Neuer Raum"; +?> + +
+

+ +
+ + + + +
+ Allgemein + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ Polygon auf Stockwerkskarte (optional) + + + +
+ + + +
+ +
+ +

Waehle zuerst ein Stockwerk mit Karte. Dann Punkte per Klick setzen, Punkte per Drag verschieben.

+
+
+ +
+ + Abbrechen +
+
+
+ + + + diff --git a/app/modules/rooms/list.php b/app/modules/rooms/list.php new file mode 100644 index 0000000..f3435e3 --- /dev/null +++ b/app/modules/rooms/list.php @@ -0,0 +1,12 @@ + +
+

Raeume

+

Die Raumverwaltung befindet sich unter Standorte.

+

Zu Standorte wechseln

+
diff --git a/app/modules/rooms/save.php b/app/modules/rooms/save.php new file mode 100644 index 0000000..c4cf750 --- /dev/null +++ b/app/modules/rooms/save.php @@ -0,0 +1,115 @@ + 0 ? "?module=rooms&action=edit&id=$roomId" : "?module=rooms&action=edit&floor_id=$floorId"; + header("Location: $redirect"); + exit; +} + +$polygonPoints = []; +$polygonJson = null; +$x = null; +$y = null; +$width = null; +$height = null; + +if ($rawPolygon !== '') { + $decoded = json_decode($rawPolygon, true); + if (is_array($decoded)) { + foreach ($decoded as $point) { + if (!is_array($point)) { + continue; + } + $px = isset($point['x']) ? (float)$point['x'] : null; + $py = isset($point['y']) ? (float)$point['y'] : null; + if (!is_finite($px) || !is_finite($py)) { + continue; + } + $polygonPoints[] = [ + 'x' => (int)round($px), + 'y' => (int)round($py), + ]; + } + } +} + +if (count($polygonPoints) >= 3) { + $xs = array_column($polygonPoints, 'x'); + $ys = array_column($polygonPoints, 'y'); + $minX = min($xs); + $maxX = max($xs); + $minY = min($ys); + $maxY = max($ys); + $x = (int)$minX; + $y = (int)$minY; + $width = (int)max(1, $maxX - $minX); + $height = (int)max(1, $maxY - $minY); + $polygonJson = json_encode($polygonPoints, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); +} + +if (roomsHasPolygonColumn($sql)) { + if ($roomId > 0) { + $sql->set( + "UPDATE rooms + SET floor_id = ?, name = ?, number = ?, x = ?, y = ?, width = ?, height = ?, polygon_points = ?, comment = ? + WHERE id = ?", + "issiiiissi", + [$floorId, $name, $number, $x, $y, $width, $height, $polygonJson, $comment, $roomId] + ); + } else { + $sql->set( + "INSERT INTO rooms (floor_id, name, number, x, y, width, height, polygon_points, comment) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + "issiiiiss", + [$floorId, $name, $number, $x, $y, $width, $height, $polygonJson, $comment] + ); + } +} else { + if ($roomId > 0) { + $sql->set( + "UPDATE rooms + SET floor_id = ?, name = ?, number = ?, x = ?, y = ?, width = ?, height = ?, comment = ? + WHERE id = ?", + "issiiiisi", + [$floorId, $name, $number, $x, $y, $width, $height, $comment, $roomId] + ); + } else { + $sql->set( + "INSERT INTO rooms (floor_id, name, number, x, y, width, height, comment) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + "issiiiis", + [$floorId, $name, $number, $x, $y, $width, $height, $comment] + ); + } +} + +header('Location: ?module=locations&action=list'); +exit; + +function roomsHasPolygonColumn($sql) +{ + static $hasColumn = null; + if ($hasColumn !== null) { + return $hasColumn; + } + $col = $sql->single("SHOW COLUMNS FROM rooms LIKE 'polygon_points'", "", []); + $hasColumn = !empty($col); + return $hasColumn; +} diff --git a/doc/DATABASE.md b/doc/DATABASE.md index 1df743d..893cfe6 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -69,6 +69,7 @@ Ein Raum innerhalb eines Stockwerks. **Grafische Attribute** - `x`, `y`, `width`, `height` zur visuellen Darstellung im Stockwerks-SVG +- `polygon_points` (JSON) fuer optionale Freiform-Polygone auf Basis der Stockwerkskarte --- diff --git a/init.sql b/init.sql index 87ad7ef..a78af85 100644 --- a/init.sql +++ b/init.sql @@ -428,6 +428,7 @@ CREATE TABLE `rooms` ( `y` int(11) DEFAULT NULL, `width` int(11) DEFAULT NULL, `height` int(11) DEFAULT NULL, + `polygon_points` longtext DEFAULT NULL, `comment` text DEFAULT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_uca1400_ai_ci;