Neues Feature: Interaktive Netzwerk-Visualisierung mit Cytoscape.js - Raeume als Compound-Container, Geraete als Nodes - Kabelverbindungen als Edges (auch raumuebergreifend) - Zwei Layout-Modi: Raeumlich (cose-bilkent) / Technisch (dagre) - Zoom/Pan/Fit, Mausrad-Zoom, Node-Positionen speicherbar - Kabeltyp-Legende, Viewport-Persistenz - Admin-Setting KUNDENKARTE_DEFAULT_VIEW (tree/graph) Verbindungsformular verbessert: - Select-Dropdowns zeigen nur Geraete (keine Gebaeude) - Icons via select2, Gebaeude-Pfad als Kontext - Systemuebergreifende Auswahl, Dolibarr-Action-Konvention Bugfixes: - Kontakt-Redirect nach Verbindung-Bearbeitung - contactid in allen Edit-URLs von contact_anlagen.php Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
850 lines
24 KiB
JavaScript
850 lines
24 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: '',
|
|
socId: 0,
|
|
contactId: 0,
|
|
isContact: false,
|
|
tooltipEl: null,
|
|
wheelZoomEnabled: false,
|
|
hasPositions: false,
|
|
_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.bindZoomButtons();
|
|
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>';
|
|
|
|
$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-Felder) + Trennlinie + Badge-Felder
|
|
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 (v === '1' && f.display === 'badge') v = '\u2713';
|
|
|
|
if (f.display === 'parentheses') {
|
|
parenParts.push(v);
|
|
} else if (f.display === 'badge') {
|
|
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);
|
|
}
|
|
// Badge-Felder als Karten-Zeilen
|
|
if (badgeLines.length > 0) {
|
|
lines.push('─────────────');
|
|
for (var j = 0; j < badgeLines.length; j++) {
|
|
lines.push(badgeLines[j]);
|
|
}
|
|
}
|
|
|
|
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,
|
|
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();
|
|
},
|
|
|
|
/**
|
|
* Cytoscape Stylesheet
|
|
*/
|
|
getStylesheet: function() {
|
|
var faFont = '"Font Awesome 5 Free", "FontAwesome", sans-serif';
|
|
|
|
return [
|
|
// Räume/Gebäude (Compound-Container)
|
|
{
|
|
selector: '.building-node',
|
|
style: {
|
|
'shape': 'roundrectangle',
|
|
'background-color': '#2a2b2d',
|
|
'background-opacity': 1,
|
|
'border-width': 2,
|
|
'border-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': '#ffffff',
|
|
'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
|
|
{
|
|
selector: '.device-node',
|
|
style: {
|
|
'shape': 'roundrectangle',
|
|
'width': 'label',
|
|
'height': 'label',
|
|
'padding': '14px',
|
|
'background-color': '#2d4a3a',
|
|
'border-width': 2,
|
|
'border-color': '#5a9a6a',
|
|
'label': 'data(display_label)',
|
|
'font-family': faFont,
|
|
'font-weight': 'bold',
|
|
'text-valign': 'center',
|
|
'text-halign': 'center',
|
|
'text-justification': 'left',
|
|
'font-size': '11px',
|
|
'color': '#ffffff',
|
|
'text-wrap': 'wrap',
|
|
'text-max-width': '220px',
|
|
'line-height': 1.4
|
|
}
|
|
},
|
|
// Kabel - Farbe aus medium_color, Fallback grün
|
|
{
|
|
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': 'bezier',
|
|
'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'
|
|
}
|
|
},
|
|
// Hover
|
|
{
|
|
selector: 'node:active',
|
|
style: {
|
|
'overlay-opacity': 0.1,
|
|
'overlay-color': '#7ab0d4'
|
|
}
|
|
},
|
|
// Selektiert
|
|
{
|
|
selector: ':selected',
|
|
style: {
|
|
'border-color': '#d4944a',
|
|
'border-width': 3
|
|
}
|
|
}
|
|
];
|
|
},
|
|
|
|
/**
|
|
* 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();
|
|
});
|
|
|
|
// 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 (inkl. Kinder bei Compound-Nodes)
|
|
this.cy.on('dragfree', 'node', function(evt) {
|
|
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 };
|
|
});
|
|
});
|
|
}
|
|
|
|
// Debounced speichern (500ms nach letztem Drag)
|
|
if (self._saveTimer) clearTimeout(self._saveTimer);
|
|
self._saveTimer = setTimeout(function() {
|
|
self.savePositions();
|
|
}, 500);
|
|
});
|
|
|
|
// 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();
|
|
});
|
|
},
|
|
|
|
/**
|
|
* 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();
|
|
// Überschrift: Icon + Bezeichnung
|
|
var 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>';
|
|
|
|
if (data.fields && data.fields.length > 0) {
|
|
for (var i = 0; i < data.fields.length && i < 10; i++) {
|
|
var f = data.fields[i];
|
|
if (!f.value || f.value === '') continue;
|
|
var val = f.value;
|
|
if (val === '1') val = '\u2713';
|
|
html += '<div class="tooltip-field">';
|
|
html += '<span class="tooltip-field-label">' + this.escapeHtml(f.label) + '</span>';
|
|
if (f.display === 'badge') {
|
|
var bgColor = f.color || '#4a5568';
|
|
html += '<span class="tooltip-field-value" style="background:' + bgColor + ';padding:1px 6px;border-radius:3px;color:#fff;">' + this.escapeHtml(String(val)) + '</span>';
|
|
} else {
|
|
html += '<span class="tooltip-field-value">' + this.escapeHtml(String(val)) + '</span>';
|
|
}
|
|
html += '</div>';
|
|
}
|
|
}
|
|
|
|
if (data.image_count > 0 || data.doc_count > 0) {
|
|
html += '<div class="tooltip-type" style="margin-top:4px;">';
|
|
if (data.image_count > 0) html += '<i class="fa fa-image"></i> ' + data.image_count + ' ';
|
|
if (data.doc_count > 0) html += '<i class="fa fa-file"></i> ' + data.doc_count;
|
|
html += '</div>';
|
|
}
|
|
|
|
this.tooltipEl.innerHTML = html;
|
|
this.tooltipEl.style.display = 'block';
|
|
this.positionTooltip(position);
|
|
},
|
|
|
|
/**
|
|
* Edge-Tooltip
|
|
*/
|
|
showEdgeTooltip: function(edge, position) {
|
|
var data = edge.data();
|
|
var html = '<div class="tooltip-title">Verbindung</div>';
|
|
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>';
|
|
}
|
|
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.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
|
|
});
|
|
}
|
|
});
|
|
|
|
})();
|