diff --git a/BUGS.md b/BUGS.md index 703d0ba..697c89d 100644 --- a/BUGS.md +++ b/BUGS.md @@ -1,4 +1,6 @@ # gefundene bugs -- 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/js/device-type-shape-editor.js b/app/assets/js/device-type-shape-editor.js index c1e5d4e..ffe42de 100644 --- a/app/assets/js/device-type-shape-editor.js +++ b/app/assets/js/device-type-shape-editor.js @@ -1,5 +1,6 @@ (() => { const SVG_NS = 'http://www.w3.org/2000/svg'; + const MIN_DRAW_SIZE = 4; function initEditor() { const editor = document.getElementById('device-type-shape-editor'); @@ -38,7 +39,13 @@ font_size: editor.querySelector('[data-field="font_size"]') }; - let draggedTemplate = null; + let activeToolType = null; + let drawState = { + active: false, + shapeId: null, + startX: 0, + startY: 0 + }; let dragState = { active: false, shapeId: null, @@ -48,63 +55,21 @@ let selectedShapeId = null; let shapes = normalizeShapeList(readJson(hiddenInput.value)); - bindToolbarDragEvents(editor); - bindCanvasDropEvents(svg); + bindToolbarEvents(editor); bindCanvasPointerEvents(svg); bindOverlayEvents(overlay); render(); - function bindToolbarDragEvents(root) { + function bindToolbarEvents(root) { const tools = root.querySelectorAll('.shape-tool[data-shape-template]'); tools.forEach((tool) => { - tool.addEventListener('dragstart', (event) => { - draggedTemplate = String(tool.dataset.shapeTemplate || '').trim(); - if (event.dataTransfer) { - event.dataTransfer.effectAllowed = 'copy'; - event.dataTransfer.setData('text/plain', draggedTemplate); - } + tool.addEventListener('click', () => { + const toolType = String(tool.dataset.shapeTemplate || '').trim(); + activeToolType = activeToolType === toolType ? null : toolType; + drawState.active = false; + drawState.shapeId = null; + renderToolState(); }); - - tool.addEventListener('dragend', () => { - draggedTemplate = null; - }); - }); - } - - function bindCanvasDropEvents(canvas) { - const canvasWrap = canvas.closest('.shape-editor-canvas'); - if (!canvasWrap) { - return; - } - - canvasWrap.addEventListener('dragover', (event) => { - if (!draggedTemplate) { - return; - } - event.preventDefault(); - canvasWrap.classList.add('drag-over'); - if (event.dataTransfer) { - event.dataTransfer.dropEffect = 'copy'; - } - }); - - canvasWrap.addEventListener('dragleave', () => { - canvasWrap.classList.remove('drag-over'); - }); - - canvasWrap.addEventListener('drop', (event) => { - if (!draggedTemplate) { - return; - } - event.preventDefault(); - canvasWrap.classList.remove('drag-over'); - - const point = toSvgPoint(event, canvas); - const shape = createDefaultShape(draggedTemplate, point.x, point.y); - shapes.push(shape); - selectedShapeId = shape.id; - persist(); - render(); }); } @@ -114,6 +79,34 @@ 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) { @@ -131,7 +124,6 @@ selectedShapeId = shape.id; renderOverlay(); - const point = toSvgPoint(event, canvas); const anchor = getShapeAnchor(shape); dragState = { active: true, @@ -144,6 +136,18 @@ }); 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; } @@ -162,13 +166,31 @@ }); 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(); }); } @@ -225,10 +247,19 @@ } function render() { + renderToolState(); 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').forEach((el) => el.remove()); @@ -299,6 +330,38 @@ } } + 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'); @@ -381,7 +444,7 @@ y: Math.round(y), width: 0, height: 0, - radius: 26, + radius: 1, text: '', fontSize: 16, fill: '#cfe6ff', @@ -416,8 +479,8 @@ type: 'rect', x: Math.round(x), y: Math.round(y), - width: 120, - height: 60, + width: 1, + height: 1, radius: 0, text: '', fontSize: 16, 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/modules/device_types/edit.php b/app/modules/device_types/edit.php index 3c1b1c6..d69e41f 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. + +