- buildTerminalPhaseMap: Schritt 1b - Leitungen mit expliziter Farbe als Startpunkte (nur Gerät→Gerät, keine Abgänge) - buildTerminalPhaseMap: Block-Durchreichung (Top↔Bottom) entfernt - buildTerminalPhaseMap: Junction-Verbindungen (Terminal→Leitung) bidirektional verarbeitet via _connectionById Index - PWA: Abgangs-Rendering mit Index-Fallback wenn source_terminal_id fehlt - PWA: Abgangs-Labels max-height 130px, min-height 30px - Auto-Naming: EquipmentCarrier create/update → 'R' + count - Auto-Naming: EquipmentPanel update → 'Feld ' + count - pwa_api.php: Hardcoded Fallbacks 'Feld'/'Hutschiene' entfernt - pwa.js: Hutschiene Auto-Naming dynamisch aus Panel-Carrier-Anzahl - kundenkarte.js: Carrier-Dialog Placeholder 'z.B. R1 (automatisch)' - SW Cache auf v12.5 hochgezählt Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1443 lines
42 KiB
JavaScript
Executable file
1443 lines
42 KiB
JavaScript
Executable file
/**
|
|
* 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
|
|
});
|
|
}
|
|
});
|
|
|
|
})();
|