/**
* 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');
}
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': '120px',
'min-height': '60px'
}
},
// 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
}
},
// 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;
}
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_', '');
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
var anlageId = node.data('id').replace('n_', '');
self._dirtyNodes[anlageId] = { id: parseInt(anlageId), x: snappedX, y: snappedY };
// 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 sichern (für Abbrechen)
this._savedPositions = {};
var self = this;
this.cy.nodes().forEach(function(node) {
var pos = node.position();
self._savedPositions[node.data('id')] = { x: pos.x, y: pos.y };
});
// 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');
},
/**
* Bearbeitungsmodus beenden (nach Speichern)
*/
exitEditMode: function() {
if (!this.cy) return;
this.editMode = false;
this._savedPositions = {};
// 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');
},
/**
* Bearbeitungsmodus abbrechen - Positionen 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 });
}
});
this._dirtyNodes = {};
this.exitEditMode();
},
/**
* 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 = '';
// Hover-Felder anzeigen (nach Position sortiert, vom Backend gefiltert)
var hoverFields = data.hover_fields || [];
if (hoverFields.length > 0) {
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 += '';
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
});
}
});
})();