kundenkarte/js/kundenkarte_cytoscape.js
data 6b3b6d7e95 feat(schematic): Terminal-Farben, Leitungen hinter Blöcken, Zeichenmodus v11.0
Terminal-Farben nach Verbindung:
- Terminals zeigen Farbe der angeschlossenen Leitung
- Grau = keine Verbindung, farbig = Leitung angeschlossen
- Neue Hilfsfunktion getTerminalConnectionColor()

Leitungen hinter Blöcken:
- Layer-Reihenfolge geändert: connections vor blocks
- Professionelleres Erscheinungsbild

Zeichenmodus-Verbesserungen:
- Rechtsklick/Escape bricht nur Linie ab, nicht Modus
- Crosshair-Cursor überall im SVG während Zeichenmodus
- 30px Hit-Area für bessere Klickbarkeit

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 13:44:52 +01:00

1443 lines
42 KiB
JavaScript

/**
* 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(
'<div class="kundenkarte-graph-empty">' +
'<i class="fa fa-sitemap"></i>' +
'<span>Keine Elemente vorhanden</span>' +
'</div>'
);
},
/**
* 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 += '<span class="kundenkarte-graph-legend-item">';
html += '<span class="kundenkarte-graph-legend-box building"></span> Raum/Gebäude</span>';
html += '<span class="kundenkarte-graph-legend-item">';
html += '<span class="kundenkarte-graph-legend-box device"></span> Gerät</span>';
// Dynamische Kabeltypen mit Farben
if (cableTypes && cableTypes.length > 0) {
for (var i = 0; i < cableTypes.length; i++) {
var ct = cableTypes[i];
html += '<span class="kundenkarte-graph-legend-item">';
html += '<span class="kundenkarte-graph-legend-line" style="background:' + ct.color + ';"></span> ';
html += this.escapeHtml(ct.label) + '</span>';
}
}
// Durchgeschleift (immer anzeigen)
html += '<span class="kundenkarte-graph-legend-item">';
html += '<span class="kundenkarte-graph-legend-line passthrough"></span> Durchgeschleift</span>';
// Hierarchie (Eltern→Kind Beziehung zwischen Geräten)
html += '<span class="kundenkarte-graph-legend-item">';
html += '<span class="kundenkarte-graph-legend-line hierarchy"></span> Hierarchie</span>';
$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(self.moduleUrl + '/ajax/anlage.php', {
action: 'toggle_decommissioned',
anlage_id: anlageId,
token: $('input[name="token"]').val() || ''
}, 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 = $('<div class="kundenkarte-resize-handle resize-' + pos + '" data-node="' + nodeId + '" data-pos="' + pos + '"></div>');
$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 = '<div class="tooltip-header" style="border-left:3px solid ' + accentColor + ';padding-left:10px;">';
// Überschrift: Icon + Bezeichnung
html += '<div class="tooltip-title">';
if (data.type_picto) {
html += '<i class="fa ' + this.escapeHtml(data.type_picto) + '"></i> ';
}
html += this.escapeHtml(data.label) + '</div>';
html += '</div>';
// Hover-Felder anzeigen (nach Position sortiert, vom Backend gefiltert)
var hoverFields = data.hover_fields || [];
if (hoverFields.length > 0) {
html += '<div class="tooltip-fields">';
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 += '<div class="tooltip-field">';
html += '<span class="tooltip-field-label">' + this.escapeHtml(f.label) + '</span>';
if (f.color) {
html += '<span class="tooltip-field-badge" style="background:' + f.color + ';">' + this.escapeHtml(String(val)) + '</span>';
} else {
html += '<span class="tooltip-field-value">' + this.escapeHtml(String(val)) + '</span>';
}
html += '</div>';
}
html += '</div>';
}
// Datei-Info am unteren Rand
if (data.image_count > 0 || data.doc_count > 0) {
html += '<div class="tooltip-footer">';
if (data.image_count > 0) html += '<span class="tooltip-file-badge"><i class="fa fa-image"></i> ' + data.image_count + '</span>';
if (data.doc_count > 0) html += '<span class="tooltip-file-badge"><i class="fa fa-file-text-o"></i> ' + data.doc_count + '</span>';
html += '</div>';
}
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 = '<div class="tooltip-header" style="border-left:3px solid ' + edgeColor + ';padding-left:10px;">';
html += '<div class="tooltip-title"><i class="fa fa-plug"></i> Verbindung</div>';
html += '</div>';
html += '<div class="tooltip-fields">';
if (data.medium_type) {
html += '<div class="tooltip-field"><span class="tooltip-field-label">Typ</span><span class="tooltip-field-value">' + this.escapeHtml(data.medium_type) + '</span></div>';
}
if (data.medium_spec) {
html += '<div class="tooltip-field"><span class="tooltip-field-label">Spezifikation</span><span class="tooltip-field-value">' + this.escapeHtml(data.medium_spec) + '</span></div>';
}
if (data.medium_length) {
html += '<div class="tooltip-field"><span class="tooltip-field-label">Länge</span><span class="tooltip-field-value">' + this.escapeHtml(data.medium_length) + '</span></div>';
}
html += '</div>';
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
});
}
});
})();