/** * KundenKarte Graph-Ansicht (Cytoscape.js) * Hierarchisches Top-Down Layout (dagre) * Gebäude-Typen als Container, Geräte darin, Kabel als Verbindungen * Keine Pfeile - Stromleitungen haben keine Richtung * * Copyright (C) 2026 Alles Watt lauft */ (function() { 'use strict'; window.KundenKarte = window.KundenKarte || {}; // FontAwesome 5 Unicode-Mapping für Cytoscape-Labels var FA_ICONS = { 'fa-home': '\uf015', 'fa-building': '\uf1ad', 'fa-warehouse': '\uf494', 'fa-compass': '\uf14e', 'fa-utensils': '\uf2e7', 'fa-bed': '\uf236', 'fa-bath': '\uf2cd', 'fa-car': '\uf1b9', 'fa-car-side': '\uf5e4', 'fa-tree': '\uf1bb', 'fa-leaf': '\uf06c', 'fa-sun': '\uf185', 'fa-child': '\uf1ae', 'fa-desktop': '\uf108', 'fa-server': '\uf233', 'fa-cogs': '\uf085', 'fa-tools': '\uf7d9', 'fa-box': '\uf466', 'fa-door-open': '\uf52b', 'fa-door-closed': '\uf52a', 'fa-square': '\uf0c8', 'fa-charging-station': '\uf5e7', 'fa-database': '\uf1c0', 'fa-fire': '\uf06d', 'fa-solar-panel': '\uf5ba', 'fa-tachometer-alt': '\uf3fd', 'fa-th-list': '\uf00b', 'fa-bolt': '\uf0e7', 'fa-plus-square': '\uf0fe', 'fa-level-down-alt': '\uf3be', 'fa-level-up-alt': '\uf3bf', 'fa-layer-group': '\uf5fd', 'fa-arrows-alt-h': '\uf337', 'fa-tshirt': '\uf553', 'fa-mountain': '\uf6fc', 'fa-horse': '\uf6f0', 'fa-toilet': '\uf7d8', 'fa-couch': '\uf4b8', 'fa-plug': '\uf1e6', 'fa-sitemap': '\uf0e8' }; KundenKarte.Graph = { cy: null, containerId: 'kundenkarte-graph-container', ajaxUrl: '', saveUrl: '', moduleUrl: '', pageUrl: '', systemId: 0, socId: 0, contactId: 0, isContact: false, canEdit: false, canDelete: false, tooltipEl: null, contextMenuEl: null, contextMenuNodeId: null, wheelZoomEnabled: false, hasPositions: false, editMode: false, _savedPositions: {}, _saveTimer: null, _dirtyNodes: {}, _viewportTimer: null, /** * Initialisierung */ init: function(config) { this.socId = config.socId || 0; this.contactId = config.contactId || 0; this.isContact = config.isContact || false; this.createTooltipElement(); this.initContextMenu(); this.bindZoomButtons(); this.bindSearch(); this.loadGraphData(); }, /** * FontAwesome-Klasse in Unicode-Zeichen umwandeln */ getIconChar: function(picto) { if (!picto) return ''; var parts = picto.split(' '); for (var i = 0; i < parts.length; i++) { if (parts[i].indexOf('fa-') === 0 && FA_ICONS[parts[i]]) { return FA_ICONS[parts[i]]; } } return ''; }, /** * Tooltip-Element erzeugen */ createTooltipElement: function() { this.tooltipEl = document.createElement('div'); this.tooltipEl.className = 'kundenkarte-graph-tooltip'; this.tooltipEl.style.display = 'none'; document.body.appendChild(this.tooltipEl); }, /** * Daten vom Server laden */ loadGraphData: function() { var self = this; var url = this.ajaxUrl + '?socid=' + this.socId; if (this.contactId > 0) { url += '&contactid=' + this.contactId; } $.ajax({ url: url, type: 'GET', dataType: 'json', success: function(response) { $('#' + self.containerId + ' .kundenkarte-graph-loading').remove(); if (response.success) { if (response.elements.nodes.length === 0) { self.showEmpty(); } else { self.hasPositions = !!response.has_positions; try { self.renderGraph(response.elements); } catch(e) { console.error('KundenKarte Graph Fehler:', e); } // Legende IMMER rendern (auch bei Graph-Fehler) self.renderLegend(response.cable_types || []); } } else { if (typeof KundenKarte.showError === 'function') { KundenKarte.showError('Fehler', response.error || 'Daten konnten nicht geladen werden'); } } }, error: function() { $('#' + self.containerId + ' .kundenkarte-graph-loading').remove(); if (typeof KundenKarte.showError === 'function') { KundenKarte.showError('Fehler', 'Netzwerkfehler beim Laden der Graph-Daten'); } } }); }, /** * Leeren Zustand anzeigen */ showEmpty: function() { $('#' + this.containerId).html( '
' + '' + 'Keine Elemente vorhanden' + '
' ); }, /** * Dynamische Legende rendern (Gebäude + Geräte + Kabeltypen) */ renderLegend: function(cableTypes) { var $legend = $('#kundenkarte-graph-legend'); if (!$legend.length) return; var html = ''; // Feste Einträge: Raum + Gerät html += ''; html += ' Raum/Gebäude'; html += ''; html += ' Gerät'; // Dynamische Kabeltypen mit Farben if (cableTypes && cableTypes.length > 0) { for (var i = 0; i < cableTypes.length; i++) { var ct = cableTypes[i]; html += ''; html += ' '; html += this.escapeHtml(ct.label) + ''; } } // Durchgeschleift (immer anzeigen) html += ''; html += ' Durchgeschleift'; // Hierarchie (Eltern→Kind Beziehung zwischen Geräten) html += ''; html += ' Hierarchie'; $legend.html(html); }, /** * Rastergröße für Snap-to-Grid (unsichtbar) */ gridSize: 20, /** * Maximaler Zoom beim Einpassen (verhindert zu starkes Reinzoomen) */ maxFitZoom: 0.85, /** * LocalStorage-Key für Viewport-State */ _viewportKey: function() { return 'kundenkarte_graph_vp_' + this.socId + '_' + this.contactId; }, /** * Viewport (Zoom + Pan) im localStorage speichern */ saveViewport: function() { if (!this.cy) return; try { localStorage.setItem(this._viewportKey(), JSON.stringify({ zoom: this.cy.zoom(), pan: this.cy.pan() })); } catch(e) { /* localStorage voll/nicht verfügbar */ } }, /** * Viewport aus localStorage wiederherstellen * @return {boolean} true wenn wiederhergestellt */ restoreViewport: function() { if (!this.cy) return false; try { var saved = localStorage.getItem(this._viewportKey()); if (saved) { var vp = JSON.parse(saved); this.cy.viewport({ zoom: vp.zoom, pan: vp.pan }); return true; } } catch(e) { /* ungültige Daten */ } return false; }, /** * Graph rendern */ renderGraph: function(elements) { var self = this; // Labels zusammenbauen: Name + Klammer-Felder + Badge-Felder var cyElements = []; if (elements.nodes) { elements.nodes.forEach(function(n) { var icon = self.getIconChar(n.data.type_picto); var namePart = icon ? (icon + ' ' + n.data.label) : n.data.label; if (n.data.is_building) { // Gebäude: Icon + Name + evtl. Klammer-Felder var parens = []; if (n.data.fields) { for (var i = 0; i < n.data.fields.length; i++) { var f = n.data.fields[i]; if (f.display === 'parentheses' && f.value) { parens.push(f.value); } } } n.data.display_label = parens.length > 0 ? namePart + ' (' + parens.join(', ') + ')' : namePart; } else { // Gerät: Name (+ Klammer) + Typ + Trennlinie + Badge-Werte mit Feldbezeichnung var parenParts = []; var badgeLines = []; if (n.data.fields) { for (var i = 0; i < n.data.fields.length; i++) { var f = n.data.fields[i]; var v = f.value; if (!v || v === '') continue; if (f.type === 'checkbox' && (v === '1' || v === 'true')) v = '\u2713'; if (f.display === 'parentheses') { parenParts.push(v); } else if (f.display === 'badge') { // Feldname: Wert badgeLines.push(f.label + ': ' + v); } } } var lines = []; // Zeile 1: Name + Klammer-Werte if (parenParts.length > 0) { lines.push(namePart + ' (' + parenParts.join(', ') + ')'); } else { lines.push(namePart); } // Trennlinie + Typ + Badge-Werte var hasDetails = (n.data.type_label || badgeLines.length > 0); if (hasDetails) { lines.push('────────────────────'); } // Typ-Bezeichnung immer unter dem Strich if (n.data.type_label) { lines.push('Typ: ' + n.data.type_label); } // Badge-Werte mit Feldbezeichnung for (var j = 0; j < badgeLines.length; j++) { lines.push(badgeLines[j]); } // Datei-Indikatoren var fileInfo = []; if (n.data.image_count > 0) fileInfo.push('\ud83d\uddbc ' + n.data.image_count); if (n.data.doc_count > 0) fileInfo.push('\ud83d\udcc4 ' + n.data.doc_count); if (fileInfo.length > 0) { if (badgeLines.length === 0) lines.push('────────────────────'); lines.push(fileInfo.join(' \u00b7 ')); } n.data.display_label = lines.join('\n'); } // Ausgebaut-Markierung if (n.data.decommissioned) { n.classes = (n.classes || '') + ' decommissioned'; } cyElements.push(n); }); } if (elements.edges) { elements.edges.forEach(function(e) { cyElements.push(e); }); } // Einpassen, aber nicht über maxFitZoom hinaus zoomen var fitWithLimit = function(cy) { cy.fit(undefined, 50); if (cy.zoom() > self.maxFitZoom) { cy.zoom(self.maxFitZoom); cy.center(); } }; // Container-Höhe an Inhalt anpassen var autoResizeContainer = function(cy) { var bb = cy.elements().boundingBox(); if (!bb || bb.w === 0) return; // Benötigte Höhe bei aktuellem Zoom + Padding var neededHeight = (bb.h * cy.zoom()) + 100; var container = document.getElementById(self.containerId); var minH = 300; var maxH = window.innerHeight * 0.8; var newH = Math.max(minH, Math.min(maxH, neededHeight)); if (Math.abs(newH - container.clientHeight) > 20) { container.style.height = Math.round(newH) + 'px'; cy.resize(); } }; // Cytoscape-Instanz erstellen (ohne Layout - wird separat gestartet) this.cy = cytoscape({ container: document.getElementById(this.containerId), elements: cyElements, style: this.getStylesheet(), minZoom: 0.15, maxZoom: 4, userZoomingEnabled: false, userPanningEnabled: true, boxSelectionEnabled: false, autoungrabify: true, layout: { name: 'preset' } }); // dagre-Layout NACH Instanz-Erstellung starten (self.cy ist jetzt gesetzt) this.cy.layout({ name: 'dagre', rankDir: 'TB', nodeSep: 50, rankSep: 80, fit: false, stop: function() { if (self.hasPositions) { // Gespeicherte Positionen anwenden self.cy.nodes().forEach(function(node) { var d = node.data(); if (d.graph_x !== null && d.graph_y !== null) { node.position({ x: d.graph_x, y: d.graph_y }); } }); // Container an Inhalt anpassen, dann Viewport wiederherstellen autoResizeContainer(self.cy); if (!self.restoreViewport()) { fitWithLimit(self.cy); } } else { // Kein gespeichertes Layout: dagre-Ergebnis einpassen autoResizeContainer(self.cy); fitWithLimit(self.cy); } } }).run(); this.bindGraphEvents(); }, /** * Dolibarr CSS-Variablen auslesen (Fallback für nicht-gesetzte Werte) */ getCssVar: function(name, fallback) { try { var val = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); return val || fallback; } catch(e) { return fallback; } }, /** * Cytoscape Stylesheet */ getStylesheet: function() { var faFont = '"Font Awesome 5 Free", "FontAwesome", sans-serif'; // Dolibarr-Schriftfarbe verwenden var textColor = this.getCssVar('--colortext', '#dcdcdc'); return [ // Räume/Gebäude (Compound-Container) { selector: '.building-node', style: { 'shape': 'roundrectangle', 'background-color': '#2a2b2d', 'background-opacity': 1, 'border-width': 2, 'border-color': function(node) { return node.data('type_color') || '#4390dc'; }, 'border-style': 'dashed', 'padding': '25px', 'label': 'data(display_label)', 'font-family': faFont, 'font-weight': 'bold', 'text-valign': 'top', 'text-halign': 'center', 'font-size': '13px', 'color': textColor, 'text-margin-y': -8, 'text-background-color': '#2a2b2d', 'text-background-opacity': 0.95, 'text-background-padding': '4px', 'text-background-shape': 'roundrectangle', 'min-width': function(node) { return node.data('graph_width') || 120; }, 'min-height': function(node) { return node.data('graph_height') || 60; } } }, // Geräte - Karten-Design mit Feldwerten (kompakt) { selector: '.device-node', style: { 'shape': 'roundrectangle', 'width': 'label', 'height': 'label', 'padding': '12px', 'background-color': '#2d3d35', 'background-opacity': 0.95, 'border-width': 2, 'border-color': function(node) { return node.data('type_color') || '#5a9a6a'; }, 'label': 'data(display_label)', 'font-family': faFont, 'font-weight': 900, 'text-valign': 'center', 'text-halign': 'center', 'text-justification': 'left', 'font-size': '11px', 'color': textColor, 'text-wrap': 'wrap', 'text-max-width': '240px', 'line-height': 1.5 } }, // Kabel - Farbe aus medium_color, Fallback grün, rechtwinklig { selector: '.cable-edge', style: { 'width': 3, 'line-color': function(edge) { return edge.data('medium_color') || '#5a8a5a'; }, 'target-arrow-shape': 'none', 'source-arrow-shape': 'none', 'curve-style': 'taxi', 'taxi-direction': 'downward', 'taxi-turn': '40px', 'label': 'data(label)', 'font-size': '9px', 'color': '#8a9aa8', 'text-rotation': 'autorotate', 'text-background-color': '#1a2030', 'text-background-opacity': 0.85, 'text-background-padding': '3px', 'text-background-shape': 'roundrectangle' } }, // Durchgeschleifte Leitungen - gestrichelt { selector: '.passthrough-edge', style: { 'width': 1, 'line-color': '#505860', 'line-style': 'dashed', 'target-arrow-shape': 'none', 'source-arrow-shape': 'none', 'curve-style': 'bezier' } }, // Hierarchie-Kanten (Gerät→Gerät Eltern-Kind, rechtwinklig) { selector: '.hierarchy-edge', style: { 'width': 1.5, 'line-color': '#6a7a8a', 'line-style': 'dotted', 'target-arrow-shape': 'none', 'source-arrow-shape': 'none', 'curve-style': 'taxi', 'taxi-direction': 'downward', 'taxi-turn': '30px', 'opacity': 0.6 } }, // Ausgebaute Elemente - ausgegraut { selector: '.decommissioned', style: { 'opacity': 0.35, 'border-style': 'dashed' } }, // Hover { selector: 'node:active', style: { 'overlay-opacity': 0.1, 'overlay-color': '#7ab0d4' } }, // Selektiert { selector: ':selected', style: { 'border-color': '#d4944a', 'border-width': 3 } }, // Suche: abgedunkelte Nodes { selector: '.search-dimmed', style: { 'opacity': 0.2 } }, // Suche: hervorgehobene Treffer { selector: '.search-highlight', style: { 'border-color': '#e8a838', 'border-width': 3, 'opacity': 1 } } ]; }, /** * Kontextmenü initialisieren */ initContextMenu: function() { var self = this; this.contextMenuEl = document.getElementById('kundenkarte-graph-contextmenu'); if (!this.contextMenuEl) return; // Klick auf Menü-Eintrag $(this.contextMenuEl).on('click', '.ctx-item', function(e) { e.preventDefault(); var action = $(this).data('action'); var anlageId = self.contextMenuNodeId; if (!anlageId) return; var baseUrl = self.pageUrl + '?id=' + self.socId + '&system=' + self.systemId; switch (action) { case 'view': window.location.href = baseUrl + '&action=view&anlage_id=' + anlageId; break; case 'edit': window.location.href = baseUrl + '&action=edit&anlage_id=' + anlageId; break; case 'copy': window.location.href = baseUrl + '&action=copy&anlage_id=' + anlageId; break; case 'delete': window.location.href = baseUrl + '&action=delete&anlage_id=' + anlageId; break; case 'add-child': window.location.href = baseUrl + '&action=create&parent_id=' + anlageId; break; case 'toggle-decommissioned': var nodeEl = self.cy.$('#n_' + anlageId); var isDecomm = nodeEl.length && nodeEl.data('decommissioned'); if (isDecomm) { // Wieder einbauen - Bestätigung window.KundenKarte.showConfirm('Wieder einbauen', 'Element wieder als eingebaut markieren?', function() { $.post(window.KundenKarte.ajaxUrl + '/anlage.php', { action: 'toggle_decommissioned', anlage_id: anlageId, token: window.KundenKarte.token }, function(res) { if (res.success) { nodeEl.data('decommissioned', 0); nodeEl.removeClass('decommissioned'); } else { alert(res.error || 'Fehler'); } }, 'json'); }); } else { // Ausbauen - Dialog mit Datum window.KundenKarte.showDecommissionDialog(anlageId, function(res) { if (nodeEl.length) { nodeEl.data('decommissioned', 1); nodeEl.addClass('decommissioned'); } }); } break; } self.hideContextMenu(); }); // Klick außerhalb schließt Menü $(document).on('click', function() { self.hideContextMenu(); }); }, showContextMenu: function(node, renderedPos) { if (!this.contextMenuEl) return; this.contextMenuNodeId = node.data('id').replace('n_', ''); // Ausgebaut-Label im Kontextmenü anpassen var isDecomm = node.data('decommissioned'); var $decommLabel = $(this.contextMenuEl).find('.ctx-decommission-label'); var $decommIcon = $(this.contextMenuEl).find('.ctx-decommission i'); if ($decommLabel.length) { $decommLabel.text(isDecomm ? 'Wieder einbauen' : 'Ausbauen'); $decommIcon.attr('class', isDecomm ? 'fa fa-plug' : 'fa fa-power-off'); } var container = document.getElementById(this.containerId); var rect = container.getBoundingClientRect(); var x = rect.left + renderedPos.x; var y = rect.top + renderedPos.y + window.scrollY; // Nicht über Bildschirmrand hinaus this.contextMenuEl.style.display = 'block'; var menuW = this.contextMenuEl.offsetWidth; var menuH = this.contextMenuEl.offsetHeight; if (x + menuW > window.innerWidth) x = window.innerWidth - menuW - 10; if (y + menuH > window.scrollY + window.innerHeight) y = y - menuH; this.contextMenuEl.style.left = x + 'px'; this.contextMenuEl.style.top = y + 'px'; }, hideContextMenu: function() { if (this.contextMenuEl) { this.contextMenuEl.style.display = 'none'; this.contextMenuNodeId = null; } }, /** * Suchfunktion binden */ bindSearch: function() { var self = this; $(document).on('input', '#kundenkarte-graph-search', function() { var query = $(this).val().toLowerCase().trim(); if (!self.cy) return; if (query === '') { // Alle Nodes wieder normal anzeigen self.cy.nodes().removeClass('search-dimmed search-highlight'); return; } self.cy.nodes().forEach(function(node) { var label = (node.data('label') || '').toLowerCase(); var typeLabel = (node.data('type_label') || '').toLowerCase(); if (label.indexOf(query) !== -1 || typeLabel.indexOf(query) !== -1) { node.removeClass('search-dimmed').addClass('search-highlight'); } else { node.removeClass('search-highlight').addClass('search-dimmed'); } }); }); }, /** * Graph-Events binden */ bindGraphEvents: function() { var self = this; // Tap auf Geräte-Node -> Detail-Ansicht this.cy.on('tap', 'node.device-node', function(evt) { var node = evt.target; var anlageId = node.data('id').replace('n_', ''); var url; if (self.isContact && self.contactId > 0) { url = self.moduleUrl + '/tabs/contact_anlagen.php?id=' + self.contactId + '&action=view&anlage_id=' + anlageId; } else { url = self.moduleUrl + '/tabs/anlagen.php?id=' + self.socId + '&action=view&anlage_id=' + anlageId; } window.location.href = url; }); // Tap auf Kabel-Edge -> Verbindungs-Editor this.cy.on('tap', 'edge.cable-edge', function(evt) { var edge = evt.target; var connId = edge.data('connection_id'); if (connId) { window.location.href = self.moduleUrl + '/anlage_connection.php?id=' + connId + '&action=edit'; } }); // Mouseover Tooltip this.cy.on('mouseover', 'node', function(evt) { self.showNodeTooltip(evt.target, evt.renderedPosition); }); this.cy.on('mouseout', 'node', function() { self.hideTooltip(); }); this.cy.on('mouseover', 'edge.cable-edge', function(evt) { self.showEdgeTooltip(evt.target, evt.renderedPosition); }); this.cy.on('mouseout', 'edge', function() { self.hideTooltip(); }); // Rechtsklick → Kontextmenü this.cy.on('cxttap', 'node', function(evt) { evt.originalEvent.preventDefault(); self.hideTooltip(); self.showContextMenu(evt.target, evt.renderedPosition); }); // Klick auf Canvas schließt Kontextmenü this.cy.on('tap', function(evt) { if (evt.target === self.cy) { self.hideContextMenu(); } }); // Drag: Begrenzung auf sichtbaren Bereich this.cy.on('drag', 'node', function(evt) { var node = evt.target; var pos = node.position(); var ext = self.cy.extent(); // Etwas Rand lassen damit Node nicht am Rand klebt var margin = 20; var clampedX = Math.max(ext.x1 + margin, Math.min(ext.x2 - margin, pos.x)); var clampedY = Math.max(ext.y1 + margin, Math.min(ext.y2 - margin, pos.y)); if (clampedX !== pos.x || clampedY !== pos.y) { node.position({ x: clampedX, y: clampedY }); } }); // Drag: Snap-to-Grid + Position merken (nur im Bearbeitungsmodus) this.cy.on('dragfree', 'node', function(evt) { if (!self.editMode) return; var node = evt.target; var pos = node.position(); var grid = self.gridSize; // Auf Raster einrasten var snappedX = Math.round(pos.x / grid) * grid; var snappedY = Math.round(pos.y / grid) * grid; node.position({ x: snappedX, y: snappedY }); // Position zum Speichern vormerken (bei Buildings auch Größe) var anlageId = node.data('id').replace('n_', ''); var dirtyEntry = { id: parseInt(anlageId), x: snappedX, y: snappedY }; if (node.hasClass('building-node') && node.data('graph_width')) { dirtyEntry.w = node.data('graph_width'); dirtyEntry.h = node.data('graph_height'); } self._dirtyNodes[anlageId] = dirtyEntry; // Bei Compound-Nodes auch alle Kinder speichern var children = node.children(); if (children.length > 0) { children.forEach(function(child) { var childPos = child.position(); var childId = child.data('id').replace('n_', ''); self._dirtyNodes[childId] = { id: parseInt(childId), x: childPos.x, y: childPos.y }; // Rekursiv für verschachtelte Compounds child.children().forEach(function(grandchild) { var gcPos = grandchild.position(); var gcId = grandchild.data('id').replace('n_', ''); self._dirtyNodes[gcId] = { id: parseInt(gcId), x: gcPos.x, y: gcPos.y }; }); }); } }); // Viewport-Änderungen speichern (Pan + Zoom) this.cy.on('viewport', function() { if (self._viewportTimer) clearTimeout(self._viewportTimer); self._viewportTimer = setTimeout(function() { self.saveViewport(); }, 300); }); // Cursor this.cy.on('mouseover', 'node.device-node, edge.cable-edge', function() { document.getElementById(self.containerId).style.cursor = 'pointer'; }); this.cy.on('mouseout', 'node, edge', function() { document.getElementById(self.containerId).style.cursor = 'default'; }); }, /** * Zoom-Buttons binden */ bindZoomButtons: function() { var self = this; // Mausrad-Zoom Ein/Aus $(document).on('click', '#btn-graph-wheel-zoom', function(e) { e.preventDefault(); self.wheelZoomEnabled = !self.wheelZoomEnabled; if (self.cy) { self.cy.userZoomingEnabled(self.wheelZoomEnabled); } $(this).toggleClass('active', self.wheelZoomEnabled); $(this).attr('title', self.wheelZoomEnabled ? 'Mausrad-Zoom aus' : 'Mausrad-Zoom ein'); }); $(document).on('click', '#btn-graph-fit', function(e) { e.preventDefault(); if (self.cy) { self.cy.zoom(self.maxFitZoom); self.cy.center(); } }); $(document).on('click', '#btn-graph-zoom-in', function(e) { e.preventDefault(); if (self.cy) { self.cy.zoom({ level: self.cy.zoom() * 1.3, renderedPosition: { x: self.cy.width() / 2, y: self.cy.height() / 2 } }); } }); $(document).on('click', '#btn-graph-zoom-out', function(e) { e.preventDefault(); if (self.cy) { self.cy.zoom({ level: self.cy.zoom() / 1.3, renderedPosition: { x: self.cy.width() / 2, y: self.cy.height() / 2 } }); } }); // Layout zurücksetzen $(document).on('click', '#btn-graph-reset-layout', function(e) { e.preventDefault(); self.resetLayout(); }); // Bearbeitungsmodus: Anordnen ein/aus $(document).on('click', '#btn-graph-edit-mode', function(e) { e.preventDefault(); self.enterEditMode(); }); // Bearbeitungsmodus: Speichern $(document).on('click', '#btn-graph-save-positions', function(e) { e.preventDefault(); self.savePositions(); self.exitEditMode(); }); // Bearbeitungsmodus: Abbrechen $(document).on('click', '#btn-graph-cancel-edit', function(e) { e.preventDefault(); self.cancelEditMode(); }); // PNG-Export $(document).on('click', '#btn-graph-export-png', function(e) { e.preventDefault(); if (!self.cy) return; var png64 = self.cy.png({ output: 'base64uri', bg: getComputedStyle(document.documentElement).getPropertyValue('--colorbackbody').trim() || '#1d1e20', full: true, scale: 2 }); var link = document.createElement('a'); link.download = 'graph_export.png'; link.href = png64; link.click(); }); }, /** * Bearbeitungsmodus aktivieren - Nodes werden verschiebbar */ enterEditMode: function() { if (!this.cy || this.editMode) return; this.editMode = true; this._dirtyNodes = {}; // Aktuelle Positionen und Größen sichern (für Abbrechen) this._savedPositions = {}; this._savedSizes = {}; var self = this; this.cy.nodes().forEach(function(node) { var pos = node.position(); self._savedPositions[node.data('id')] = { x: pos.x, y: pos.y }; }); this.cy.nodes('.building-node').forEach(function(node) { self._savedSizes[node.data('id')] = { w: node.data('graph_width'), h: node.data('graph_height') }; }); // Nodes verschiebbar machen this.cy.autoungrabify(false); // Buttons umschalten $('#btn-graph-edit-mode').hide(); $('#btn-graph-save-positions, #btn-graph-cancel-edit').show(); // Visuelles Feedback: Container-Rahmen $('#' + this.containerId).addClass('graph-edit-mode'); // Resize-Handles für Gebäude this.showResizeHandles(); }, /** * Bearbeitungsmodus beenden (nach Speichern) */ exitEditMode: function() { if (!this.cy) return; this.editMode = false; this._savedPositions = {}; this._savedSizes = {}; // Nodes wieder sperren this.cy.autoungrabify(true); // Buttons zurückschalten $('#btn-graph-edit-mode').show(); $('#btn-graph-save-positions, #btn-graph-cancel-edit').hide(); // Visuelles Feedback entfernen $('#' + this.containerId).removeClass('graph-edit-mode'); // Resize-Handles entfernen this.hideResizeHandles(); }, /** * Bearbeitungsmodus abbrechen - Positionen und Größen zurücksetzen */ cancelEditMode: function() { if (!this.cy) return; // Positionen zurücksetzen var self = this; this.cy.nodes().forEach(function(node) { var saved = self._savedPositions[node.data('id')]; if (saved) { node.position({ x: saved.x, y: saved.y }); } }); // Gebäude-Größen zurücksetzen this.cy.nodes('.building-node').forEach(function(node) { var saved = self._savedSizes[node.data('id')]; if (saved) { node.data('graph_width', saved.w); node.data('graph_height', saved.h); node.style({ 'min-width': saved.w || 120, 'min-height': saved.h || 60 }); } }); this._dirtyNodes = {}; this.exitEditMode(); }, // ========================================== // Resize-Handles für Gebäude-Compound-Nodes // ========================================== _resizeHandles: [], _resizeListeners: null, /** * Resize-Handles für alle Building-Nodes anzeigen */ showResizeHandles: function() { var self = this; var $container = $('#' + this.containerId); // Container relativ positionieren falls nötig if ($container.css('position') === 'static') { $container.css('position', 'relative'); } // Handles für jedes Gebäude erstellen this.cy.nodes('.building-node').forEach(function(node) { self._createHandlesForNode(node, $container); }); // Viewport-Änderungen: Handles repositionieren this._resizeListeners = { viewport: function() { self.updateResizeHandles(); }, position: function() { self.updateResizeHandles(); }, // Nach Drag repositionieren dragfree: function() { self.updateResizeHandles(); } }; this.cy.on('viewport', this._resizeListeners.viewport); this.cy.on('position', 'node', this._resizeListeners.position); this.cy.on('dragfree', 'node', this._resizeListeners.dragfree); }, /** * Handles für einen einzelnen Building-Node erstellen */ _createHandlesForNode: function(node, $container) { var self = this; var nodeId = node.data('id'); // 4 Ecken + 4 Kanten var positions = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w']; positions.forEach(function(pos) { var $handle = $('
'); $container.append($handle); self._resizeHandles.push($handle[0]); $handle.on('mousedown', function(e) { e.preventDefault(); e.stopPropagation(); self._startResize(node, pos, e); }); }); this._positionHandles(node); }, /** * Handles eines Nodes an die aktuelle Bounding-Box positionieren */ _positionHandles: function(node) { var nodeId = node.data('id'); var bb = node.renderedBoundingBox({ includeLabels: false }); var $container = $('#' + this.containerId); var containerOffset = $container.offset(); var canvasOffset = $container.find('canvas').first().offset() || containerOffset; // Offset relativ zum Container var ox = canvasOffset.left - containerOffset.left; var oy = canvasOffset.top - containerOffset.top; var handleSize = 10; var half = handleSize / 2; var coords = { 'nw': { left: bb.x1 + ox - half, top: bb.y1 + oy - half }, 'n': { left: bb.x1 + ox + (bb.w / 2) - half, top: bb.y1 + oy - half }, 'ne': { left: bb.x2 + ox - half, top: bb.y1 + oy - half }, 'e': { left: bb.x2 + ox - half, top: bb.y1 + oy + (bb.h / 2) - half }, 'se': { left: bb.x2 + ox - half, top: bb.y2 + oy - half }, 's': { left: bb.x1 + ox + (bb.w / 2) - half, top: bb.y2 + oy - half }, 'sw': { left: bb.x1 + ox - half, top: bb.y2 + oy - half }, 'w': { left: bb.x1 + ox - half, top: bb.y1 + oy + (bb.h / 2) - half } }; $('.kundenkarte-resize-handle[data-node="' + nodeId + '"]').each(function() { var pos = $(this).data('pos'); if (coords[pos]) { $(this).css({ left: coords[pos].left + 'px', top: coords[pos].top + 'px' }); } }); }, /** * Alle Handles repositionieren (nach Zoom/Pan/Drag) */ updateResizeHandles: function() { var self = this; if (this._updateHandleTimer) clearTimeout(this._updateHandleTimer); this._updateHandleTimer = setTimeout(function() { self.cy.nodes('.building-node').forEach(function(node) { self._positionHandles(node); }); }, 16); // ~60fps }, /** * Resize starten bei mousedown auf Handle */ _startResize: function(node, handlePos, startEvent) { var self = this; var startX = startEvent.clientX; var startY = startEvent.clientY; var zoom = this.cy.zoom(); // Aktuelle Größe (model-Koordinaten, nicht rendered) var bb = node.boundingBox({ includeLabels: false }); var startW = bb.w; var startH = bb.h; // Minimale Größe var minW = 80; var minH = 40; // Node während Resize nicht verschiebbar node.ungrabify(); function onMouseMove(e) { var dx = (e.clientX - startX) / zoom; var dy = (e.clientY - startY) / zoom; var newW = startW; var newH = startH; // Breite anpassen je nach Handle-Position if (handlePos.indexOf('e') >= 0) newW = startW + dx; if (handlePos.indexOf('w') >= 0) newW = startW - dx; // Höhe anpassen if (handlePos.indexOf('s') >= 0) newH = startH + dy; if (handlePos.indexOf('n') >= 0) newH = startH - dy; // Minimum newW = Math.max(minW, newW); newH = Math.max(minH, newH); // Auf Grid rasten newW = Math.round(newW / self.gridSize) * self.gridSize; newH = Math.round(newH / self.gridSize) * self.gridSize; // Cytoscape Style aktualisieren node.style({ 'min-width': newW, 'min-height': newH }); node.data('graph_width', newW); node.data('graph_height', newH); // Handles repositionieren self._positionHandles(node); } function onMouseUp() { $(document).off('mousemove', onMouseMove); $(document).off('mouseup', onMouseUp); // Node wieder verschiebbar node.grabify(); // Endgültige Größe merken var anlageId = node.data('id').replace('n_', ''); var pos = node.position(); self._dirtyNodes[anlageId] = { id: parseInt(anlageId), x: pos.x, y: pos.y, w: node.data('graph_width') || 0, h: node.data('graph_height') || 0 }; self.updateResizeHandles(); } $(document).on('mousemove', onMouseMove); $(document).on('mouseup', onMouseUp); }, /** * Alle Resize-Handles entfernen */ hideResizeHandles: function() { $('.kundenkarte-resize-handle').remove(); this._resizeHandles = []; if (this._updateHandleTimer) { clearTimeout(this._updateHandleTimer); this._updateHandleTimer = null; } // Event-Listener entfernen if (this._resizeListeners && this.cy) { this.cy.off('viewport', this._resizeListeners.viewport); this.cy.off('position', 'node', this._resizeListeners.position); this.cy.off('dragfree', 'node', this._resizeListeners.dragfree); this._resizeListeners = null; } }, /** * Geänderte Positionen an Server senden */ savePositions: function() { var positions = []; for (var id in this._dirtyNodes) { positions.push(this._dirtyNodes[id]); } if (positions.length === 0) return; // Dirty-Liste leeren this._dirtyNodes = {}; $.ajax({ url: this.saveUrl + '?action=save', type: 'POST', contentType: 'application/json', data: JSON.stringify({ positions: positions }), dataType: 'json', success: function(resp) { if (!resp.success) { console.error('Graph-Positionen speichern fehlgeschlagen:', resp.error); } }, error: function(xhr, status, err) { console.error('Graph-Positionen Netzwerkfehler:', status, err); } }); }, /** * Layout zurücksetzen: Positionen löschen + dagre neu berechnen */ resetLayout: function() { if (!this.cy) return; var self = this; // Positionen in DB + Viewport in localStorage löschen $.ajax({ url: this.saveUrl + '?action=reset&socid=' + this.socId + (this.contactId > 0 ? '&contactid=' + this.contactId : ''), type: 'POST', dataType: 'json' }); try { localStorage.removeItem(this._viewportKey()); } catch(e) {} // dagre Layout neu berechnen this.hasPositions = false; this._dirtyNodes = {}; var cy = this.cy; var maxZoom = this.maxFitZoom; cy.layout({ name: 'dagre', rankDir: 'TB', nodeSep: 50, rankSep: 80, fit: false, animate: true, animationDuration: 400, stop: function() { // Container an neues Layout anpassen var bb = cy.elements().boundingBox(); if (bb && bb.h > 0) { var neededH = (bb.h * maxZoom) + 100; var container = document.getElementById(self.containerId); var minH = 300; var maxH = window.innerHeight * 0.8; var newH = Math.max(minH, Math.min(maxH, neededH)); container.style.height = Math.round(newH) + 'px'; cy.resize(); } cy.fit(undefined, 50); if (cy.zoom() > maxZoom) { cy.zoom(maxZoom); cy.center(); } } }).run(); }, /** * Node-Tooltip */ showNodeTooltip: function(node, position) { var data = node.data(); // Header: Typ-Farbe als Akzentlinie var accentColor = data.type_color || '#5a9a6a'; var html = '
'; // Überschrift: Icon + Bezeichnung html += '
'; if (data.type_picto) { html += ' '; } html += this.escapeHtml(data.label) + '
'; html += '
'; // Hover-Felder anzeigen (nach Position sortiert, vom Backend gefiltert) var hoverFields = data.hover_fields || []; if (hoverFields.length > 0) { html += '
'; for (var i = 0; i < hoverFields.length; i++) { var f = hoverFields[i]; if (!f.value || f.value === '') continue; var val = f.value; if (f.type === 'checkbox' && (val === '1' || val === 'true')) val = '\u2713'; if (f.type === 'checkbox' && (val === '0' || val === 'false')) val = '\u2717'; html += '
'; html += '' + this.escapeHtml(f.label) + ''; if (f.color) { html += '' + this.escapeHtml(String(val)) + ''; } else { html += '' + this.escapeHtml(String(val)) + ''; } html += '
'; } html += '
'; } // Datei-Info am unteren Rand if (data.image_count > 0 || data.doc_count > 0) { html += ''; } this.tooltipEl.innerHTML = html; this.tooltipEl.style.display = 'block'; this.positionTooltip(position); }, /** * Edge-Tooltip */ showEdgeTooltip: function(edge, position) { var data = edge.data(); // Farbe der Verbindung als Akzent var edgeColor = data.medium_color || '#5a8a5a'; var html = '
'; html += '
Verbindung
'; html += '
'; html += '
'; if (data.medium_type) { html += '
Typ' + this.escapeHtml(data.medium_type) + '
'; } if (data.medium_spec) { html += '
Spezifikation' + this.escapeHtml(data.medium_spec) + '
'; } if (data.medium_length) { html += '
Länge' + this.escapeHtml(data.medium_length) + '
'; } html += '
'; this.tooltipEl.innerHTML = html; this.tooltipEl.style.display = 'block'; this.positionTooltip(position); }, positionTooltip: function(renderedPos) { var container = document.getElementById(this.containerId); var rect = container.getBoundingClientRect(); var x = rect.left + renderedPos.x + 15; var y = rect.top + renderedPos.y - 10 + window.scrollY; if (x + 300 > window.innerWidth) { x = rect.left + renderedPos.x - 315; } this.tooltipEl.style.left = x + 'px'; this.tooltipEl.style.top = y + 'px'; }, hideTooltip: function() { if (this.tooltipEl) this.tooltipEl.style.display = 'none'; }, escapeHtml: function(str) { if (!str) return ''; var div = document.createElement('div'); div.appendChild(document.createTextNode(str)); return div.innerHTML; }, destroy: function() { if (this.cy) { this.cy.destroy(); this.cy = null; } if (this.tooltipEl && this.tooltipEl.parentNode) { this.tooltipEl.parentNode.removeChild(this.tooltipEl); this.tooltipEl = null; } } }; // Auto-Init $(document).ready(function() { var container = document.getElementById('kundenkarte-graph-container'); if (container) { KundenKarte.Graph.ajaxUrl = container.getAttribute('data-ajax-url') || ''; KundenKarte.Graph.saveUrl = container.getAttribute('data-save-url') || ''; KundenKarte.Graph.moduleUrl = container.getAttribute('data-module-url') || ''; KundenKarte.Graph.pageUrl = container.getAttribute('data-page-url') || ''; KundenKarte.Graph.systemId = parseInt(container.getAttribute('data-systemid')) || 0; KundenKarte.Graph.canEdit = container.getAttribute('data-can-edit') === '1'; KundenKarte.Graph.canDelete = container.getAttribute('data-can-delete') === '1'; KundenKarte.Graph.init({ socId: parseInt(container.getAttribute('data-socid')) || 0, contactId: parseInt(container.getAttribute('data-contactid')) || 0, isContact: container.hasAttribute('data-contactid') && parseInt(container.getAttribute('data-contactid')) > 0 }); } }); })();