diff --git a/BUGS.md b/BUGS.md index 249a5c2..697c89d 100644 --- a/BUGS.md +++ b/BUGS.md @@ -1,5 +1,6 @@ # gefundene bugs -- device-webconfig link ist nicht korrekt -- device löschen geht nicht -- device_types svg modul malen -- ports drag n drop funktioniert nicht \ No newline at end of file +- [?] device löschen geht nicht +- [?] device_types svg modul malen +- [?] ports drag n drop funktioniert nicht +- device _type soll schon aus dem 19zoll und he größe einen initialees rechteck erzeugen, welches als device grundgerüst funktionieren soll. +- beim dev typ machen, klick auf obj typ button, dann durch drag and drop die diagonale ziehen mit loslassen fixieren \ No newline at end of file diff --git a/app/assets/css/device-type-edit.css b/app/assets/css/device-type-edit.css new file mode 100644 index 0000000..3238da9 --- /dev/null +++ b/app/assets/css/device-type-edit.css @@ -0,0 +1,308 @@ +.device-type-edit { + max-width: 1200px; + margin: 20px auto; + padding: 20px; +} + +.device-type-edit .edit-form { + background: white; + padding: 20px; + border: 1px solid #ddd; + border-radius: 8px; +} + +.device-type-edit .edit-form fieldset { + margin: 20px 0; + padding: 15px; + border: 1px solid #e0e0e0; + border-radius: 4px; +} + +.device-type-edit .edit-form legend { + padding: 0 10px; + font-weight: bold; + font-size: 1.1em; +} + +.device-type-edit .form-group { + margin: 15px 0; +} + +.device-type-edit .form-group label { + display: block; + margin-bottom: 5px; + font-weight: bold; +} + +.device-type-edit .form-group input[type="text"], +.device-type-edit .form-group input[type="file"], +.device-type-edit .form-group select, +.device-type-edit .form-group textarea { + width: 100%; + padding: 8px 12px; + border: 1px solid #ddd; + border-radius: 4px; + font-family: inherit; +} + +.device-type-edit .form-group textarea { + resize: vertical; +} + +.device-type-edit .form-group small { + display: block; + margin-top: 5px; + color: #666; +} + +.device-type-edit .required { + color: red; +} + +.device-type-edit .form-file-preview { + margin-top: 10px; +} + +.device-type-edit .device-type-current-image { + max-width: 300px; + border: 1px solid #ddd; + padding: 10px; + display: block; +} + +.device-type-edit .port-definition-table { + width: 100%; + border-collapse: collapse; + margin: 15px 0; +} + +.device-type-edit .port-definition-table th, +.device-type-edit .port-definition-table td { + padding: 10px; + text-align: left; + border-bottom: 1px solid #ddd; + vertical-align: middle; +} + +.device-type-edit .port-definition-table th { + background: #f5f5f5; +} + +.device-type-edit .port-definition-table input[type="text"], +.device-type-edit .port-definition-table select { + width: 100%; + padding: 6px 8px; + border: 1px solid #ccc; + border-radius: 4px; + font-family: inherit; +} + +.device-type-edit .form-actions { + display: flex; + gap: 10px; + margin-top: 30px; +} + +.device-type-edit .shape-editor { + display: grid; + grid-template-columns: 1fr; + gap: 16px; + margin-top: 16px; +} + +.device-type-edit .shape-meta-controls { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; + margin-bottom: 16px; +} + +.device-type-edit .shape-meta-column { + display: flex; + flex-direction: column; + gap: 4px; +} + +.device-type-edit .shape-meta-column label { + font-weight: 600; + font-size: 0.9em; +} + +.device-type-edit .shape-meta-column input, +.device-type-edit .shape-meta-column select { + padding: 6px 10px; + border: 1px solid #ccc; + border-radius: 4px; + font-family: inherit; + font-size: 0.95em; +} + +.device-type-edit .port-actions { + margin-top: 15px; + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; +} + +.device-type-edit .shape-editor-canvas, +.device-type-edit .shape-toolbox, +.device-type-edit .shape-overlay { + border: 1px solid #ddd; + border-radius: 6px; + background: white; + padding: 12px; +} + +.device-type-edit .shape-toolbox, +.device-type-edit .shape-overlay { + width: 100%; +} + +.device-type-edit .shape-editor-canvas svg { + width: 100%; + min-height: 320px; + display: block; + font-family: inherit; + cursor: crosshair; +} + +.device-type-edit .shape-toolbox, +.device-type-edit .shape-overlay { + border: 1px solid #ddd; + border-radius: 6px; + padding: 12px; + background: #fff; +} + +.device-type-edit .shape-toolbox h4, +.device-type-edit .shape-overlay h4 { + margin: 0 0 8px; +} + +.device-type-edit .shape-tool-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.device-type-edit .shape-tool { + text-align: left; + border: 1px solid #bbb; + background: #f7f7f7; + color: #222; + padding: 10px; + border-radius: 4px; + cursor: grab; +} + +.device-type-edit .shape-tool:active { + cursor: grabbing; +} + +.device-type-edit .shape-tool.is-active { + background: #007bff; + color: #fff; + border-color: #0056b3; +} + +.device-type-edit .shape-editor-canvas.drag-over { + outline: 2px dashed #007bff; + outline-offset: 2px; +} + +.device-type-edit .shape-object { + cursor: move; +} + +.device-type-edit #shape-canvas.shape-tool-active { + cursor: crosshair; +} + +.device-type-edit .shape-object.is-selected { + filter: drop-shadow(0 0 5px rgba(0, 123, 255, 0.7)); +} + +.device-type-edit .shape-object.is-port { + stroke-dasharray: 4 2; +} + +.device-type-edit .shape-overlay-form .shape-control-grid { + display: grid; + grid-template-columns: repeat(2, minmax(120px, 1fr)); + gap: 8px; +} + +.device-type-edit .shape-overlay-form label { + font-size: 0.82rem; + display: flex; + flex-direction: column; + gap: 4px; +} + +.device-type-edit .shape-overlay-form input[type="number"], +.device-type-edit .shape-overlay-form input[type="text"], +.device-type-edit .shape-overlay-form input[type="color"] { + width: 100%; + padding: 6px 8px; + border: 1px solid #ccc; + border-radius: 4px; + font-family: inherit; +} + +.device-type-edit .shape-port-settings { + margin-top: 10px; +} + +.device-type-edit .inline-checkbox { + flex-direction: row !important; + align-items: center; + gap: 6px !important; +} + +.device-type-edit .shape-overlay-actions { + margin-top: 12px; + display: flex; + justify-content: flex-end; +} + +.device-type-edit .shape-overlay-empty { + margin: 6px 0 0; + color: #666; + font-size: 0.9rem; +} + +.device-type-edit .hint { + font-size: 0.8rem; + color: #666; + margin: 8px 0 0; +} + +.device-type-edit .button { + padding: 10px 15px; + background: #007bff; + color: white; + text-decoration: none; + border-radius: 4px; + border: none; + cursor: pointer; + font-size: 0.95em; +} + +.device-type-edit .button-primary { + background: #28a745; +} + +.device-type-edit .button-danger { + background: #dc3545; +} + +.device-type-edit .button:hover { + opacity: 0.8; +} + +@media (max-width: 1100px) { + .device-type-edit .shape-editor { + grid-template-columns: 1fr; + } +} diff --git a/app/assets/js/device-type-shape-editor.js b/app/assets/js/device-type-shape-editor.js new file mode 100644 index 0000000..e82e014 --- /dev/null +++ b/app/assets/js/device-type-shape-editor.js @@ -0,0 +1,766 @@ +(() => { + const SVG_NS = 'http://www.w3.org/2000/svg'; + const MIN_DRAW_SIZE = 4; + const MIN_CANVAS_WIDTH = 200; + const MIN_CANVAS_HEIGHT = 120; + const FORM_FACTOR_PRESETS = { + '19': { width: 760, height: 80 }, + '10': { width: 420, height: 80 } + }; + + let portOptionsInput = null; + + function initEditor() { + const editor = document.getElementById('device-type-shape-editor'); + const svg = document.getElementById('shape-canvas'); + const hiddenInput = document.getElementById('shape-definition'); + + if (!editor || !svg || !hiddenInput) { + return; + } + + const overlay = { + empty: document.getElementById('shape-overlay-empty'), + form: document.getElementById('shape-overlay-form'), + type: document.getElementById('shape-param-type'), + x: document.getElementById('shape-param-x'), + y: document.getElementById('shape-param-y'), + width: document.getElementById('shape-param-width'), + height: document.getElementById('shape-param-height'), + radius: document.getElementById('shape-param-radius'), + fontSize: document.getElementById('shape-param-font-size'), + text: document.getElementById('shape-param-text'), + fill: document.getElementById('shape-param-fill'), + stroke: document.getElementById('shape-param-stroke'), + strokeWidth: document.getElementById('shape-param-stroke-width'), + isPort: document.getElementById('shape-param-is-port'), + portName: document.getElementById('shape-param-port-name'), + portNameLabel: document.getElementById('shape-port-name-label'), + deleteButton: document.getElementById('shape-delete') + }; + portOptionsInput = document.getElementById('shape-port-options'); + + const metaInputs = { + formFactor: document.getElementById('shape-meta-form-factor'), + rackHeight: document.getElementById('shape-meta-rack-height'), + canvasWidth: document.getElementById('shape-meta-canvas-width'), + canvasHeight: document.getElementById('shape-meta-canvas-height') + }; + + const fieldVisibility = { + width: editor.querySelector('[data-field="width"]'), + height: editor.querySelector('[data-field="height"]'), + radius: editor.querySelector('[data-field="radius"]'), + text: editor.querySelector('[data-field="text"]'), + font_size: editor.querySelector('[data-field="font_size"]') + }; + + let activeToolType = null; + let drawState = { + active: false, + shapeId: null, + startX: 0, + startY: 0 + }; + let dragState = { + active: false, + shapeId: null, + offsetX: 0, + offsetY: 0 + }; + let selectedShapeId = null; + let shapes = []; + let meta = getDefaultMeta(); + let portNameOptions = readPortOptions(); + + const definition = readDefinition(hiddenInput.value); + shapes = normalizeShapeList(definition.shapes); + meta = definition.meta; + + bindToolbarEvents(editor); + bindCanvasPointerEvents(svg); + bindOverlayEvents(overlay); + bindMetaEvents(); + applyFormFactorPreset(); + populatePortSelect(); + applyMetaToInputs(); + persist(); + render(); + + function bindToolbarEvents(root) { + const tools = root.querySelectorAll('.shape-tool[data-shape-template]'); + tools.forEach((tool) => { + tool.addEventListener('click', () => { + const toolType = String(tool.dataset.shapeTemplate || '').trim(); + activeToolType = activeToolType === toolType ? null : toolType; + drawState.active = false; + drawState.shapeId = null; + renderToolState(); + }); + }); + } + + function bindCanvasPointerEvents(canvas) { + canvas.addEventListener('pointerdown', (event) => { + const target = event.target; + if (!(target instanceof SVGElement)) { + return; + } + const point = toSvgPoint(event, canvas); + + if (activeToolType) { + const shape = createDefaultShape(activeToolType, point.x, point.y); + shapes.push(shape); + selectedShapeId = shape.id; + + if (activeToolType === 'text') { + drawState.active = false; + drawState.shapeId = null; + persist(); + render(); + return; + } + + drawState = { + active: true, + shapeId: shape.id, + startX: Math.round(point.x), + startY: Math.round(point.y) + }; + + const shapeElement = target.closest('[data-shape-id]') || canvas; + shapeElement.setPointerCapture(event.pointerId); + persist(); + render(); + return; + } + + const shapeElement = target.closest('[data-shape-id]'); + if (!shapeElement) { + selectedShapeId = null; + renderOverlay(); + return; + } + + const shapeId = shapeElement.getAttribute('data-shape-id'); + const shape = findShape(shapeId); + if (!shape) { + return; + } + + selectedShapeId = shape.id; + renderOverlay(); + + const anchor = getShapeAnchor(shape); + dragState = { + active: true, + shapeId: shape.id, + offsetX: anchor.x - point.x, + offsetY: anchor.y - point.y + }; + + shapeElement.setPointerCapture(event.pointerId); + }); + + canvas.addEventListener('pointermove', (event) => { + if (drawState.active && drawState.shapeId) { + const shape = findShape(drawState.shapeId); + if (!shape) { + return; + } + const point = toSvgPoint(event, canvas); + updateShapeFromDiagonal(shape, drawState.startX, drawState.startY, point.x, point.y); + persist(); + render(); + return; + } + + if (!dragState.active || !dragState.shapeId) { + return; + } + + const shape = findShape(dragState.shapeId); + if (!shape) { + return; + } + + const point = toSvgPoint(event, canvas); + const x = Math.round(point.x + dragState.offsetX); + const y = Math.round(point.y + dragState.offsetY); + setShapeAnchor(shape, x, y); + persist(); + render(); + }); + + canvas.addEventListener('pointerup', () => { + if (drawState.active && drawState.shapeId) { + const shape = findShape(drawState.shapeId); + if (shape && isBelowMinDrawSize(shape)) { + shapes = shapes.filter((candidate) => candidate.id !== shape.id); + if (selectedShapeId === shape.id) { + selectedShapeId = null; + } + } + drawState.active = false; + drawState.shapeId = null; + } + + dragState.active = false; + dragState.shapeId = null; + persist(); + render(); + }); + + canvas.addEventListener('pointercancel', () => { + drawState.active = false; + drawState.shapeId = null; + dragState.active = false; + dragState.shapeId = null; + persist(); + render(); + }); + } + + function bindOverlayEvents(o) { + const inputs = [ + o.x, o.y, o.width, o.height, o.radius, o.fontSize, o.text, o.fill, o.stroke, o.strokeWidth, o.isPort, o.portName + ]; + + inputs.forEach((input) => { + if (!input) { + return; + } + const eventName = input.type === 'checkbox' ? 'change' : 'input'; + input.addEventListener(eventName, applyOverlayToSelectedShape); + }); + + o.deleteButton.addEventListener('click', () => { + if (!selectedShapeId) { + return; + } + shapes = shapes.filter((shape) => shape.id !== selectedShapeId); + selectedShapeId = null; + persist(); + render(); + }); + } + + function bindMetaEvents() { + Object.values(metaInputs).forEach((input) => { + if (!input) { + return; + } + const eventName = input.tagName === 'SELECT' ? 'change' : 'input'; + input.addEventListener(eventName, handleMetaInputChange); + }); + } + + function populatePortSelect() { + if (!overlay.portName) { + return; + } + portNameOptions = readPortOptions(); + overlay.portName.innerHTML = ''; + portNameOptions.forEach((name) => { + const option = document.createElement('option'); + option.value = name; + option.textContent = name; + overlay.portName.appendChild(option); + }); + } + + function handleMetaInputChange(event) { + const targetId = event?.target?.id; + applyMetaFromInputs(); + applyFormFactorPreset(targetId); + applyMetaToInputs(); + persist(); + render(); + } + + function applyMetaFromInputs() { + if (!metaInputs.formFactor) { + return; + } + meta.formFactor = normalizeFormFactor(metaInputs.formFactor.value); + meta.heightHe = Math.max(1, toNumberOrDefault(metaInputs.rackHeight.value, meta.heightHe)); + meta.canvasWidth = Math.max(MIN_CANVAS_WIDTH, toNumberOrDefault(metaInputs.canvasWidth.value, meta.canvasWidth)); + meta.canvasHeight = Math.max(MIN_CANVAS_HEIGHT, toNumberOrDefault(metaInputs.canvasHeight.value, meta.canvasHeight)); + } + + function applyMetaToInputs() { + if (!metaInputs.formFactor) { + return; + } + metaInputs.formFactor.value = meta.formFactor; + metaInputs.rackHeight.value = meta.heightHe; + metaInputs.canvasWidth.value = meta.canvasWidth; + metaInputs.canvasHeight.value = meta.canvasHeight; + } + + function applyFormFactorPreset(triggerId) { + if (!['shape-meta-form-factor', 'shape-meta-rack-height'].includes(triggerId) && triggerId !== undefined) { + return; + } + + const preset = FORM_FACTOR_PRESETS[meta.formFactor]; + if (!preset) { + return; + } + + meta.canvasWidth = Math.max(MIN_CANVAS_WIDTH, preset.width); + const targetHeight = Math.round(preset.height * Math.max(1, meta.heightHe)); + meta.canvasHeight = Math.max(MIN_CANVAS_HEIGHT, targetHeight); + } + + function applyOverlayToSelectedShape() { + const shape = findShape(selectedShapeId); + if (!shape) { + return; + } + + shape.x = toNumberOrDefault(overlay.x.value, shape.x); + shape.y = toNumberOrDefault(overlay.y.value, shape.y); + shape.width = toNumberOrDefault(overlay.width.value, shape.width); + shape.height = toNumberOrDefault(overlay.height.value, shape.height); + shape.radius = toNumberOrDefault(overlay.radius.value, shape.radius); + shape.fontSize = toNumberOrDefault(overlay.fontSize.value, shape.fontSize); + shape.text = String(overlay.text.value || shape.text || 'Text'); + shape.fill = normalizeColor(overlay.fill.value, shape.fill); + shape.stroke = normalizeColor(overlay.stroke.value, shape.stroke); + shape.strokeWidth = toNumberOrDefault(overlay.strokeWidth.value, shape.strokeWidth); + shape.isPort = overlay.isPort.checked; + + if (shape.isPort) { + shape.portName = String(overlay.portName.value || '').trim(); + } else { + shape.portName = ''; + } + + persist(); + render(); + } + + function render() { + renderToolState(); + updateCanvasDimensions(); + renderCanvas(); + renderOverlay(); + } + + function renderToolState() { + editor.querySelectorAll('.shape-tool[data-shape-template]').forEach((tool) => { + const type = String(tool.dataset.shapeTemplate || '').trim(); + tool.classList.toggle('is-active', activeToolType === type); + }); + svg.classList.toggle('shape-tool-active', !!activeToolType); + } + + function renderCanvas() { + svg.querySelectorAll('.shape-object, .shape-auto-frame').forEach((el) => el.remove()); + renderAutoFrame(); + + shapes.forEach((shape) => { + const element = createSvgShapeElement(shape); + if (!element) { + return; + } + element.classList.add('shape-object'); + if (shape.id === selectedShapeId) { + element.classList.add('is-selected'); + } + if (shape.isPort) { + element.classList.add('is-port'); + } + element.setAttribute('data-shape-id', shape.id); + svg.appendChild(element); + }); + } + + function renderAutoFrame() { + if (!['19', '10'].includes(meta.formFactor)) { + return; + } + + const padding = 4; + const frameWidth = Math.max(0, meta.canvasWidth - padding * 2); + const frameHeight = Math.max(0, meta.canvasHeight - padding * 2); + if (frameWidth <= 0 || frameHeight <= 0) { + return; + } + + const frame = document.createElementNS(SVG_NS, 'rect'); + frame.setAttribute('x', String(padding)); + frame.setAttribute('y', String(padding)); + frame.setAttribute('width', String(frameWidth)); + frame.setAttribute('height', String(frameHeight)); + frame.setAttribute('fill', '#ffffff'); + frame.setAttribute('stroke', '#000000'); + frame.setAttribute('stroke-width', '1'); + frame.setAttribute('class', 'shape-auto-frame'); + frame.setAttribute('pointer-events', 'none'); + svg.appendChild(frame); + } + + function updateCanvasDimensions() { + const width = Math.max(MIN_CANVAS_WIDTH, meta.canvasWidth); + const height = Math.max(MIN_CANVAS_HEIGHT, meta.canvasHeight); + svg.setAttribute('viewBox', `0 0 ${width} ${height}`); + svg.style.height = `${height}px`; + svg.style.minHeight = `${height}px`; + } + + function renderOverlay() { + const selected = findShape(selectedShapeId); + const hasSelection = !!selected; + + overlay.empty.hidden = hasSelection; + overlay.form.hidden = !hasSelection; + + if (!selected) { + return; + } + + populatePortSelect(); + + overlay.type.value = selected.type; + overlay.x.value = selected.x; + overlay.y.value = selected.y; + overlay.width.value = selected.width; + overlay.height.value = selected.height; + overlay.radius.value = selected.radius; + overlay.fontSize.value = selected.fontSize; + overlay.text.value = selected.text || ''; + overlay.fill.value = normalizeColor(selected.fill, '#cccccc'); + overlay.stroke.value = normalizeColor(selected.stroke, '#333333'); + overlay.strokeWidth.value = selected.strokeWidth; + overlay.isPort.checked = !!selected.isPort; + overlay.portName.value = selected.portName || ''; + + const isRect = selected.type === 'rect'; + const isCircle = selected.type === 'circle'; + const isText = selected.type === 'text'; + + setFieldVisible(fieldVisibility.width, isRect); + setFieldVisible(fieldVisibility.height, isRect); + setFieldVisible(fieldVisibility.radius, isCircle); + setFieldVisible(fieldVisibility.text, isText); + setFieldVisible(fieldVisibility.font_size, isText); + + overlay.portNameLabel.hidden = !selected.isPort; + } + + function persist() { + const payload = { + shapes, + meta: { + formFactor: meta.formFactor, + heightHe: meta.heightHe, + canvasWidth: meta.canvasWidth, + canvasHeight: meta.canvasHeight + } + }; + hiddenInput.value = JSON.stringify(payload); + } + + function findShape(id) { + if (!id) { + return null; + } + return shapes.find((shape) => shape.id === id) || null; + } + } + + function updateShapeFromDiagonal(shape, x1, y1, x2, y2) { + const left = Math.min(x1, x2); + const top = Math.min(y1, y2); + const width = Math.abs(x2 - x1); + const height = Math.abs(y2 - y1); + + if (shape.type === 'rect') { + shape.x = Math.round(left); + shape.y = Math.round(top); + shape.width = Math.round(width); + shape.height = Math.round(height); + return; + } + + if (shape.type === 'circle') { + const radius = Math.round(Math.min(width, height) / 2); + shape.radius = radius; + shape.x = Math.round(left + radius); + shape.y = Math.round(top + radius); + } + } + + function isBelowMinDrawSize(shape) { + if (shape.type === 'rect') { + return (shape.width ?? 0) < MIN_DRAW_SIZE || (shape.height ?? 0) < MIN_DRAW_SIZE; + } + if (shape.type === 'circle') { + return (shape.radius ?? 0) < (MIN_DRAW_SIZE / 2); + } + return false; + } + + function createSvgShapeElement(shape) { + const fill = normalizeColor(shape.fill, '#cccccc'); + const stroke = normalizeColor(shape.stroke, '#333333'); + const strokeWidth = shape.strokeWidth > 0 ? shape.strokeWidth : 1; + + if (shape.type === 'rect') { + const rect = document.createElementNS(SVG_NS, 'rect'); + rect.setAttribute('x', String(shape.x)); + rect.setAttribute('y', String(shape.y)); + rect.setAttribute('width', String(shape.width)); + rect.setAttribute('height', String(shape.height)); + rect.setAttribute('fill', fill); + rect.setAttribute('stroke', stroke); + rect.setAttribute('stroke-width', String(strokeWidth)); + return rect; + } + + if (shape.type === 'circle') { + const circle = document.createElementNS(SVG_NS, 'circle'); + circle.setAttribute('cx', String(shape.x)); + circle.setAttribute('cy', String(shape.y)); + circle.setAttribute('r', String(shape.radius)); + circle.setAttribute('fill', fill); + circle.setAttribute('stroke', stroke); + circle.setAttribute('stroke-width', String(strokeWidth)); + return circle; + } + + if (shape.type === 'text') { + const text = document.createElementNS(SVG_NS, 'text'); + text.setAttribute('x', String(shape.x)); + text.setAttribute('y', String(shape.y)); + text.setAttribute('fill', fill); + text.setAttribute('font-size', String(shape.fontSize)); + text.setAttribute('text-anchor', 'start'); + text.setAttribute('dominant-baseline', 'hanging'); + text.textContent = shape.text || 'Text'; + return text; + } + + return null; + } + + function normalizeShapeList(value) { + if (!Array.isArray(value)) { + return []; + } + + return value.map((shape, index) => { + const normalizedType = ['rect', 'circle', 'text'].includes(shape.type) ? shape.type : 'rect'; + const legacyRadius = toNumberOrDefault(shape.r, 26); + + return { + id: String(shape.id || `shape_${Date.now()}_${index}`), + type: normalizedType, + x: toNumberOrDefault(shape.x, 20), + y: toNumberOrDefault(shape.y, 20), + width: toNumberOrDefault(shape.width, 120), + height: toNumberOrDefault(shape.height, 60), + radius: toNumberOrDefault(shape.radius, legacyRadius), + text: String(shape.text || 'Text'), + fontSize: toNumberOrDefault(shape.fontSize, 16), + fill: normalizeColor(shape.fill, '#cccccc'), + stroke: normalizeColor(shape.stroke, '#333333'), + strokeWidth: toNumberOrDefault(shape.strokeWidth, 1), + isPort: !!shape.isPort, + portName: String(shape.portName || '') + }; + }); + } + + function createDefaultShape(type, x, y) { + const uid = `shape_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`; + + if (type === 'circle') { + return { + id: uid, + type: 'circle', + x: Math.round(x), + y: Math.round(y), + width: 0, + height: 0, + radius: 1, + text: '', + fontSize: 16, + fill: '#cfe6ff', + stroke: '#1f5f99', + strokeWidth: 1, + isPort: false, + portName: '' + }; + } + + if (type === 'text') { + return { + id: uid, + type: 'text', + x: Math.round(x), + y: Math.round(y), + width: 0, + height: 0, + radius: 0, + text: 'Text', + fontSize: 16, + fill: '#2a2a2a', + stroke: '#2a2a2a', + strokeWidth: 0, + isPort: false, + portName: '' + }; + } + + return { + id: uid, + type: 'rect', + x: Math.round(x), + y: Math.round(y), + width: 1, + height: 1, + radius: 0, + text: '', + fontSize: 16, + fill: '#d9e8b3', + stroke: '#4d5f27', + strokeWidth: 1, + isPort: false, + portName: '' + }; + } + + function getShapeAnchor(shape) { + if (shape.type === 'circle') { + return { x: shape.x, y: shape.y }; + } + return { x: shape.x, y: shape.y }; + } + + function setShapeAnchor(shape, x, y) { + shape.x = x; + shape.y = y; + } + + function setFieldVisible(field, visible) { + if (!field) { + return; + } + field.hidden = !visible; + } + + function toSvgPoint(event, svg) { + const pt = svg.createSVGPoint(); + pt.x = event.clientX; + pt.y = event.clientY; + + const ctm = svg.getScreenCTM(); + if (!ctm) { + return { x: 0, y: 0 }; + } + + const p = pt.matrixTransform(ctm.inverse()); + return { x: p.x, y: p.y }; + } + + function toNumberOrDefault(value, fallback) { + const n = Number(value); + return Number.isFinite(n) ? n : fallback; + } + + function readJson(raw) { + if (!raw || !String(raw).trim()) { + return []; + } + + try { + return JSON.parse(raw); + } catch (error) { + return []; + } + } + + function readPortOptions() { + if (!portOptionsInput) { + return []; + } + + const raw = String(portOptionsInput.value || '').trim(); + if (!raw) { + return []; + } + + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return []; + } + return parsed.filter((value) => typeof value === 'string'); + } catch (error) { + return []; + } + } + + function readDefinition(raw) { + const parsed = readJson(raw); + if (Array.isArray(parsed)) { + return { + shapes: parsed, + meta: getDefaultMeta() + }; + } + + if (parsed && typeof parsed === 'object') { + return { + shapes: Array.isArray(parsed.shapes) ? parsed.shapes : [], + meta: normalizeMeta(parsed.meta ?? parsed) + }; + } + + return { + shapes: [], + meta: getDefaultMeta() + }; + } + + function normalizeMeta(source) { + const base = getDefaultMeta(); + if (!source || typeof source !== 'object') { + return base; + } + + return { + formFactor: normalizeFormFactor(source.formFactor ?? source.rackFormFactor ?? base.formFactor), + heightHe: Math.max(1, toNumberOrDefault(source.heightHe ?? source.rackHeight ?? base.heightHe, base.heightHe)), + canvasWidth: Math.max(MIN_CANVAS_WIDTH, toNumberOrDefault(source.canvasWidth ?? source.width ?? base.canvasWidth, base.canvasWidth)), + canvasHeight: Math.max(MIN_CANVAS_HEIGHT, toNumberOrDefault(source.canvasHeight ?? source.height ?? base.canvasHeight, base.canvasHeight)) + }; + } + + function getDefaultMeta() { + return { + formFactor: 'other', + heightHe: 1, + canvasWidth: 800, + canvasHeight: 360 + }; + } + + function normalizeFormFactor(value) { + const normalized = String(value || '').trim(); + return ['10', '19'].includes(normalized) ? normalized : 'other'; + } + + function normalizeColor(value, fallback) { + const v = String(value || '').trim(); + if (/^#[0-9a-fA-F]{6}$/.test(v)) { + return v; + } + return fallback; + } + + document.addEventListener('DOMContentLoaded', initEditor); +})(); diff --git a/app/assets/js/floor-svg-editor.js b/app/assets/js/floor-svg-editor.js new file mode 100644 index 0000000..0f2e083 --- /dev/null +++ b/app/assets/js/floor-svg-editor.js @@ -0,0 +1,471 @@ +(() => { + const SVG_NS = 'http://www.w3.org/2000/svg'; + const VIEWBOX_WIDTH = 2000; + const VIEWBOX_HEIGHT = 1000; + const SNAP_TOLERANCE = 12; + + function initFloorSvgEditor() { + const editor = document.getElementById('floor-svg-editor'); + const svg = document.getElementById('floor-svg-canvas'); + const hiddenInput = document.getElementById('floor-svg-content'); + if (!editor || !svg || !hiddenInput) { + return; + } + + const controls = { + startPolyline: document.getElementById('floor-start-polyline'), + finishPolyline: document.getElementById('floor-finish-polyline'), + deletePolyline: document.getElementById('floor-delete-polyline'), + clearDrawing: document.getElementById('floor-clear-drawing'), + lock45: document.getElementById('floor-lock-45'), + snapGuides: document.getElementById('floor-snap-guides'), + addGuide: document.getElementById('floor-add-guide'), + guideOrientation: document.getElementById('floor-guide-orientation'), + guidePosition: document.getElementById('floor-guide-position'), + guideList: document.getElementById('floor-guide-list') + }; + + const state = { + polylines: [], + guides: [], + selectedPolylineId: null, + activePolylineId: null, + draggingVertex: null + }; + + loadFromExistingSvg(hiddenInput.value, state); + bindControlEvents(controls, state, svg, hiddenInput); + bindCanvasEvents(svg, controls, state, hiddenInput); + render(svg, controls, state, hiddenInput); + } + + function bindControlEvents(controls, state, svg, hiddenInput) { + controls.startPolyline.addEventListener('click', () => { + const id = createId('poly'); + state.polylines.push({ + id, + points: [] + }); + state.activePolylineId = id; + state.selectedPolylineId = id; + render(svg, controls, state, hiddenInput); + }); + + controls.finishPolyline.addEventListener('click', () => { + finishActivePolyline(state); + render(svg, controls, state, hiddenInput); + }); + + controls.deletePolyline.addEventListener('click', () => { + if (!state.selectedPolylineId) { + return; + } + state.polylines = state.polylines.filter((line) => line.id !== state.selectedPolylineId); + if (state.activePolylineId === state.selectedPolylineId) { + state.activePolylineId = null; + } + state.selectedPolylineId = null; + render(svg, controls, state, hiddenInput); + }); + + controls.clearDrawing.addEventListener('click', () => { + state.polylines = []; + state.guides = []; + state.selectedPolylineId = null; + state.activePolylineId = null; + state.draggingVertex = null; + render(svg, controls, state, hiddenInput); + }); + + controls.addGuide.addEventListener('click', () => { + const orientation = controls.guideOrientation.value === 'horizontal' ? 'horizontal' : 'vertical'; + const position = Number(controls.guidePosition.value); + if (!Number.isFinite(position)) { + return; + } + state.guides.push({ + id: createId('guide'), + orientation, + position: Math.round(position) + }); + controls.guidePosition.value = ''; + render(svg, controls, state, hiddenInput); + }); + + controls.guideList.addEventListener('click', (event) => { + const target = event.target; + if (!(target instanceof HTMLElement)) { + return; + } + const id = target.getAttribute('data-remove-guide'); + if (!id) { + return; + } + state.guides = state.guides.filter((guide) => guide.id !== id); + render(svg, controls, state, hiddenInput); + }); + } + + function bindCanvasEvents(svg, controls, state, hiddenInput) { + svg.addEventListener('pointerdown', (event) => { + const target = event.target; + if (!(target instanceof SVGElement)) { + return; + } + + const vertex = target.closest('[data-vertex-index]'); + if (vertex) { + const polylineId = vertex.getAttribute('data-polyline-id'); + const vertexIndex = Number(vertex.getAttribute('data-vertex-index')); + if (polylineId && Number.isInteger(vertexIndex)) { + state.selectedPolylineId = polylineId; + state.draggingVertex = { polylineId, vertexIndex }; + vertex.setPointerCapture(event.pointerId); + } + return; + } + + const polylineEl = target.closest('[data-polyline-id]'); + if (polylineEl) { + const polylineId = polylineEl.getAttribute('data-polyline-id'); + if (polylineId) { + state.selectedPolylineId = polylineId; + if (!state.activePolylineId) { + state.activePolylineId = polylineId; + } + render(svg, controls, state, hiddenInput); + } + return; + } + + const point = toSvgPoint(svg, event); + if (!point) { + return; + } + + if (!state.activePolylineId) { + const id = createId('poly'); + state.polylines.push({ id, points: [] }); + state.activePolylineId = id; + state.selectedPolylineId = id; + } + + const activeLine = state.polylines.find((line) => line.id === state.activePolylineId); + if (!activeLine) { + return; + } + + let nextPoint = point; + if (controls.lock45.checked && activeLine.points.length > 0) { + nextPoint = lockTo45(activeLine.points[activeLine.points.length - 1], nextPoint); + } + if (controls.snapGuides.checked) { + nextPoint = snapPointToGuides(nextPoint, state.guides, SNAP_TOLERANCE); + } + + activeLine.points.push({ + x: Math.round(nextPoint.x), + y: Math.round(nextPoint.y) + }); + render(svg, controls, state, hiddenInput); + }); + + svg.addEventListener('pointermove', (event) => { + if (!state.draggingVertex) { + return; + } + const point = toSvgPoint(svg, event); + if (!point) { + return; + } + + const line = state.polylines.find((item) => item.id === state.draggingVertex.polylineId); + if (!line) { + return; + } + const index = state.draggingVertex.vertexIndex; + if (!line.points[index]) { + return; + } + + let nextPoint = point; + if (controls.lock45.checked && index > 0 && line.points[index - 1]) { + nextPoint = lockTo45(line.points[index - 1], nextPoint); + } + if (controls.snapGuides.checked) { + nextPoint = snapPointToGuides(nextPoint, state.guides, SNAP_TOLERANCE); + } + + line.points[index] = { + x: Math.round(nextPoint.x), + y: Math.round(nextPoint.y) + }; + render(svg, controls, state, hiddenInput); + }); + + svg.addEventListener('pointerup', () => { + state.draggingVertex = null; + }); + svg.addEventListener('pointercancel', () => { + state.draggingVertex = null; + }); + } + + function render(svg, controls, state, hiddenInput) { + const selected = state.polylines.find((line) => line.id === state.selectedPolylineId) || null; + + svg.innerHTML = ''; + svg.setAttribute('viewBox', `0 0 ${VIEWBOX_WIDTH} ${VIEWBOX_HEIGHT}`); + + const background = createSvgElement('rect'); + background.setAttribute('x', '0'); + background.setAttribute('y', '0'); + background.setAttribute('width', String(VIEWBOX_WIDTH)); + background.setAttribute('height', String(VIEWBOX_HEIGHT)); + background.setAttribute('fill', '#fafafa'); + background.setAttribute('stroke', '#e1e1e1'); + background.setAttribute('stroke-width', '1'); + svg.appendChild(background); + + state.guides.forEach((guide) => { + const line = createSvgElement('line'); + if (guide.orientation === 'horizontal') { + line.setAttribute('x1', '0'); + line.setAttribute('y1', String(guide.position)); + line.setAttribute('x2', String(VIEWBOX_WIDTH)); + line.setAttribute('y2', String(guide.position)); + } else { + line.setAttribute('x1', String(guide.position)); + line.setAttribute('y1', '0'); + line.setAttribute('x2', String(guide.position)); + line.setAttribute('y2', String(VIEWBOX_HEIGHT)); + } + line.setAttribute('stroke', '#8f8f8f'); + line.setAttribute('stroke-width', '1'); + line.setAttribute('stroke-dasharray', '8 6'); + line.setAttribute('opacity', '0.8'); + line.setAttribute('data-guide-id', guide.id); + svg.appendChild(line); + }); + + state.polylines.forEach((polyline) => { + const line = createSvgElement('polyline'); + line.setAttribute('fill', 'none'); + line.setAttribute('stroke', polyline.id === state.selectedPolylineId ? '#007bff' : '#1f2937'); + line.setAttribute('stroke-width', polyline.id === state.selectedPolylineId ? '5' : '3'); + line.setAttribute('points', polyline.points.map((point) => `${point.x},${point.y}`).join(' ')); + line.setAttribute('data-polyline-id', polyline.id); + svg.appendChild(line); + }); + + if (selected) { + selected.points.forEach((point, index) => { + const vertex = createSvgElement('circle'); + vertex.setAttribute('cx', String(point.x)); + vertex.setAttribute('cy', String(point.y)); + vertex.setAttribute('r', '8'); + vertex.setAttribute('fill', '#ffffff'); + vertex.setAttribute('stroke', '#dc3545'); + vertex.setAttribute('stroke-width', '3'); + vertex.setAttribute('data-polyline-id', selected.id); + vertex.setAttribute('data-vertex-index', String(index)); + svg.appendChild(vertex); + }); + } + + controls.guideList.innerHTML = ''; + state.guides.forEach((guide) => { + const li = document.createElement('li'); + const label = guide.orientation === 'horizontal' ? 'Horizontal' : 'Vertikal'; + li.innerHTML = ` + ${label}: ${Math.round(guide.position)} + + `; + controls.guideList.appendChild(li); + }); + + hiddenInput.value = buildSvgMarkup(state.polylines, state.guides); + } + + function finishActivePolyline(state) { + const active = state.polylines.find((line) => line.id === state.activePolylineId); + if (active && active.points.length < 2) { + state.polylines = state.polylines.filter((line) => line.id !== active.id); + if (state.selectedPolylineId === active.id) { + state.selectedPolylineId = null; + } + } + state.activePolylineId = null; + } + + function loadFromExistingSvg(raw, state) { + const content = String(raw || '').trim(); + if (!content) { + return; + } + + try { + const parser = new DOMParser(); + const doc = parser.parseFromString(content, 'image/svg+xml'); + const root = doc.documentElement; + if (!root || root.nodeName.toLowerCase() === 'parsererror') { + return; + } + + root.querySelectorAll('line[data-guide="1"], line.floor-guide').forEach((line) => { + const orientation = line.getAttribute('data-orientation') === 'horizontal' ? 'horizontal' : 'vertical'; + const position = orientation === 'horizontal' + ? Number(line.getAttribute('y1')) + : Number(line.getAttribute('x1')); + if (Number.isFinite(position)) { + state.guides.push({ + id: createId('guide'), + orientation, + position: Math.round(position) + }); + } + }); + + root.querySelectorAll('polyline').forEach((polyline) => { + const pointsAttr = polyline.getAttribute('points') || ''; + const points = parsePoints(pointsAttr); + if (points.length < 2) { + return; + } + state.polylines.push({ + id: createId('poly'), + points + }); + }); + + if (state.polylines.length > 0) { + state.selectedPolylineId = state.polylines[0].id; + } + } catch (error) { + // ignore invalid svg content + } + } + + function parsePoints(pointsAttr) { + return pointsAttr + .trim() + .split(/\s+/) + .map((pair) => { + const [x, y] = pair.split(',').map((value) => Number(value)); + if (!Number.isFinite(x) || !Number.isFinite(y)) { + return null; + } + return { x: Math.round(x), y: Math.round(y) }; + }) + .filter(Boolean); + } + + function buildSvgMarkup(polylines, guides) { + const svg = createSvgElement('svg'); + svg.setAttribute('xmlns', SVG_NS); + svg.setAttribute('viewBox', `0 0 ${VIEWBOX_WIDTH} ${VIEWBOX_HEIGHT}`); + + const background = createSvgElement('rect'); + background.setAttribute('x', '0'); + background.setAttribute('y', '0'); + background.setAttribute('width', String(VIEWBOX_WIDTH)); + background.setAttribute('height', String(VIEWBOX_HEIGHT)); + background.setAttribute('fill', '#fafafa'); + background.setAttribute('stroke', '#e1e1e1'); + background.setAttribute('stroke-width', '1'); + svg.appendChild(background); + + guides.forEach((guide) => { + const line = createSvgElement('line'); + if (guide.orientation === 'horizontal') { + line.setAttribute('x1', '0'); + line.setAttribute('y1', String(guide.position)); + line.setAttribute('x2', String(VIEWBOX_WIDTH)); + line.setAttribute('y2', String(guide.position)); + } else { + line.setAttribute('x1', String(guide.position)); + line.setAttribute('y1', '0'); + line.setAttribute('x2', String(guide.position)); + line.setAttribute('y2', String(VIEWBOX_HEIGHT)); + } + line.setAttribute('stroke', '#8f8f8f'); + line.setAttribute('stroke-width', '1'); + line.setAttribute('stroke-dasharray', '8 6'); + line.setAttribute('class', 'floor-guide'); + line.setAttribute('data-guide', '1'); + line.setAttribute('data-orientation', guide.orientation); + svg.appendChild(line); + }); + + polylines.forEach((polyline) => { + if (polyline.points.length < 2) { + return; + } + const line = createSvgElement('polyline'); + line.setAttribute('fill', 'none'); + line.setAttribute('stroke', '#1f2937'); + line.setAttribute('stroke-width', '3'); + line.setAttribute('class', 'floor-polyline'); + line.setAttribute('points', polyline.points.map((point) => `${point.x},${point.y}`).join(' ')); + svg.appendChild(line); + }); + + return new XMLSerializer().serializeToString(svg); + } + + function lockTo45(origin, point) { + const dx = point.x - origin.x; + const dy = point.y - origin.y; + const length = Math.sqrt((dx * dx) + (dy * dy)); + if (length === 0) { + return { x: origin.x, y: origin.y }; + } + + const angle = Math.atan2(dy, dx); + const step = Math.PI / 4; + const snapped = Math.round(angle / step) * step; + return { + x: origin.x + Math.cos(snapped) * length, + y: origin.y + Math.sin(snapped) * length + }; + } + + function snapPointToGuides(point, guides, tolerance) { + let next = { ...point }; + guides.forEach((guide) => { + if (guide.orientation === 'vertical') { + if (Math.abs(next.x - guide.position) <= tolerance) { + next.x = guide.position; + } + } else if (Math.abs(next.y - guide.position) <= tolerance) { + next.y = guide.position; + } + }); + return next; + } + + 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 + }; + } + + function createSvgElement(name) { + return document.createElementNS(SVG_NS, name); + } + + function createId(prefix) { + return `${prefix}_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`; + } + + document.addEventListener('DOMContentLoaded', initFloorSvgEditor); +})(); diff --git a/app/assets/js/network-view.js b/app/assets/js/network-view.js index ae7c1c1..e5fd5d6 100644 --- a/app/assets/js/network-view.js +++ b/app/assets/js/network-view.js @@ -12,6 +12,7 @@ * -> bewusst simpel & erweiterbar */ +(() => { /* ========================= * Konfiguration * ========================= */ @@ -287,3 +288,4 @@ document.addEventListener('keydown', (e) => { // TODO: Delete -> Gerät entfernen? }); +})(); diff --git a/app/assets/js/svg-editor.js b/app/assets/js/svg-editor.js index 709fb4c..e94e4a3 100644 --- a/app/assets/js/svg-editor.js +++ b/app/assets/js/svg-editor.js @@ -12,6 +12,7 @@ * Abhängigkeiten: keine (Vanilla JS) */ +(() => { /* ========================= * Konfiguration * ========================= */ @@ -256,3 +257,4 @@ document.addEventListener('keydown', (e) => { deleteSelectedPort(); } }); +})(); diff --git a/app/index.php b/app/index.php index c663dee..78c3bbc 100644 --- a/app/index.php +++ b/app/index.php @@ -30,7 +30,7 @@ $action = $_GET['action'] ?? 'list'; $validModules = ['dashboard', 'locations', 'buildings', 'device_types', 'devices', 'racks', 'floors', 'connections']; // Whitelist der Aktionen -$validActions = ['list', 'edit', 'save', 'ports']; +$validActions = ['list', 'edit', 'save', 'ports', 'delete']; // Prüfen auf gültige Werte if (!in_array($module, $validModules)) { @@ -44,9 +44,9 @@ if (!in_array($action, $validActions)) { } /* ========================= - * Template-Header laden (nur für non-save Aktionen) + * Template-Header laden (nur für View-Aktionen) * ========================= */ -if ($action !== 'save') { +if (!in_array($action, ['save', 'delete'], true)) { require_once __DIR__ . '/templates/header.php'; } @@ -65,8 +65,8 @@ if (file_exists($modulePath)) { } /* ========================= - * Template-Footer laden (nur für non-save Aktionen) + * Template-Footer laden (nur für View-Aktionen) * ========================= */ -if ($action !== 'save') { +if (!in_array($action, ['save', 'delete'], true)) { require_once __DIR__ . '/templates/footer.php'; } diff --git a/app/modules/connections/save.php b/app/modules/connections/save.php index fc76a94..dbad99f 100644 --- a/app/modules/connections/save.php +++ b/app/modules/connections/save.php @@ -67,56 +67,3 @@ $_SESSION['success'] = "Verbindung gespeichert"; // ========================= header('Location: ?module=connections&action=list'); exit; - "type": "device_position" | "port_position" | "network_layout" | ... - "entity_id": 123, - "payload": { ... } -} -*/ - -// TODO: Pflichtfelder prüfen -// $type = $data['type'] ?? null; -// $entityId = $data['entity_id'] ?? null; -// $payload = $data['payload'] ?? null; - -// ========================= -// Routing nach Typ -// ========================= - -switch ($type ?? null) { - - case 'device_position': - // TODO: - // - Gerät-ID validieren - // - SVG-Koordinaten speichern - // - ggf. Zoom / Rotation - break; - - case 'port_position': - // TODO: - // - Device-Type-Port-ID - // - Koordinaten relativ zum SVG - break; - - case 'network_layout': - // TODO: - // - Kontext (Standort / Rack) - // - Gerätepositionen - // - Verbindungskurven - break; - - default: - http_response_code(400); - echo json_encode([ - 'error' => 'Unknown save type' - ]); - exit; -} - -// ========================= -// Antwort -// ========================= - -// TODO: Erfolg / Fehler zurückgeben -echo json_encode([ - 'status' => 'ok' -]); diff --git a/app/modules/device_types/edit.php b/app/modules/device_types/edit.php index 7ac02f0..e642bee 100644 --- a/app/modules/device_types/edit.php +++ b/app/modules/device_types/edit.php @@ -35,6 +35,12 @@ $shapeDefinition = $deviceType['shape_definition'] ?? '[]'; if (trim($shapeDefinition) === '') { $shapeDefinition = '[]'; } +$portTypes = $sql->get( + "SELECT id, name FROM port_types ORDER BY name", + "", + [] +); +$defaultPortTypeSelected = (int)($_POST['default_port_type_id'] ?? 0); $isEdit = !empty($deviceType); $pageTitle = $isEdit ? "Gerätetyp bearbeiten: " . htmlspecialchars($deviceType['name']) : "Neuer Gerätetyp"; @@ -86,6 +92,19 @@ $pageTitle = $isEdit ? "Gerätetyp bearbeiten: " . htmlspecialchars($deviceType[ placeholder="z.B. 48"> Beim Speichern werden bis zu dieser Zahl Platzhalter-Ports erstellt, bestehende Einträge bleiben erhalten. + +
+ + + Wird beim automatischen Erstellen neuer Ports als Startwert gesetzt. +
Allgemein @@ -58,8 +55,7 @@ $buildings = $sql->get("SELECT id, name FROM buildings ORDER BY name", "", []); - Dient zur Sortierung + placeholder="z.B. 0 für EG, 1 für 1. OG">
@@ -69,19 +65,15 @@ $buildings = $sql->get("SELECT id, name FROM buildings ORDER BY name", "", []);
-
Standort -
- Optionales Floorplan-SVG. Kann später im Editor bearbeitet werden. + Optional. Wenn ein Zeichnungsinhalt im Editor erstellt wird, wird dieser beim Speichern bevorzugt.
- -
- -

+ + +
+
+

Zeichenwerkzeug

+ + + + + + + +
+ +
+ +

Linien müssen nicht geschlossen sein. Klick auf freie Fläche fügt Punkte hinzu, Punkte sind per Drag verschiebbar.

+
+ +
+

Hilfslinien

+
+ + + +
+
    +
    -
    -
    Abbrechen - - Löschen -
    - - - ========================= --> - -
    - Grundriss / Floorplan - -
    - - - -
    - -

    - Räume und Netzwerkdosen per Drag & Drop platzieren. Nummerierung und Bezeichnungen editierbar. -

    -
    - - - -
    - - - -
    - - - - - - + diff --git a/app/modules/floors/save.php b/app/modules/floors/save.php index 604e926..a5bf8dd 100644 --- a/app/modules/floors/save.php +++ b/app/modules/floors/save.php @@ -19,6 +19,7 @@ $name = trim($_POST['name'] ?? ''); $buildingId = (int)($_POST['building_id'] ?? 0); $level = isset($_POST['level']) ? (int)$_POST['level'] : null; $comment = trim($_POST['comment'] ?? ''); +$floorSvgContent = trim($_POST['floor_svg_content'] ?? ''); // ========================= // Validierung @@ -79,6 +80,17 @@ if (!empty($_FILES['svg_file']['name'])) { } } +if ($floorSvgContent !== '') { + $storedSvgPath = storeSvgEditorContent($sql, $floorId, $floorSvgContent); + if ($storedSvgPath === false) { + $_SESSION['error'] = "SVG aus dem Editor konnte nicht gespeichert werden"; + $redirectUrl = $floorId ? "?module=floors&action=edit&id=$floorId" : "?module=floors&action=edit"; + header("Location: $redirectUrl"); + exit; + } + $svgPath = $storedSvgPath; +} + // ========================= // In DB speichern // ========================= @@ -113,3 +125,41 @@ $_SESSION['success'] = "Stockwerk gespeichert"; // ========================= header('Location: ?module=floors&action=list'); exit; + +function storeSvgEditorContent($sql, $floorId, $content) +{ + $normalized = trim($content); + if ($normalized === '' || stripos($normalized, ' 0) { + $existing = $sql->single( + "SELECT svg_path FROM floors WHERE id = ?", + "i", + [$floorId] + ); + $candidate = trim((string)($existing['svg_path'] ?? '')); + if ($candidate !== '') { + $relativePath = ltrim($candidate, "/\\"); + } + } + + if (!$relativePath) { + $relativePath = 'uploads/floorplans/' . uniqid('floor_') . '.svg'; + } + + $absolutePath = __DIR__ . '/../../' . $relativePath; + $targetDir = dirname($absolutePath); + if (!is_dir($targetDir)) { + mkdir($targetDir, 0755, true); + } + + $written = file_put_contents($absolutePath, $normalized); + if ($written === false) { + return false; + } + + return $relativePath; +} diff --git a/app/templates/header.php b/app/templates/header.php index 2930bac..68533a8 100644 --- a/app/templates/header.php +++ b/app/templates/header.php @@ -14,6 +14,7 @@ + diff --git a/doc/DATABASE.md b/doc/DATABASE.md index 0bf31ad..de0e4f7 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -120,7 +120,7 @@ Definiert eine Gerätevorlage. - Grundlage für alle grafischen Ansichten **Technische Attribute** -- `shape_definition`: JSON-Array mit einfachen Formen (rect/circle/text) für die integrierte Zeichenfläche. +- `shape_definition`: JSON-Objekt mit `meta` und `shapes`. `meta` enthält den Formfaktor (`'10'`, `'19'`, `'other'`), die Rack-Höhe in HE (`heightHe`) sowie die Zeichenflächen-Abmessungen (`canvasWidth`, `canvasHeight`). Bei 1HE-Geräten mit 10" oder 19" Frontpanel wird der Zeichenbereich automatisch auf die dort üblichen Breiten/Höhen (420×80px bzw. 760×80px) skaliert und zusätzlich von einem weißen Rahmen mit schwarzer Linie umgeben, damit die Darstellung den realen Formfaktor besser widerspiegelt. Der `shapes`-Array enthält wie bisher die einzelnen Formen (`rect`, `circle`, `text`), die im Editor platziert wurden. ---