PWA Mobile App für Schaltschrank-Dokumentation vor Ort: - Token-basierte Authentifizierung (15 Tage gültig) - Kundensuche mit Offline-Cache - Anlagen-Auswahl und Offline-Laden - Felder/Hutschienen/Automaten erfassen - Automatische Synchronisierung wenn wieder online - Installierbar auf dem Smartphone Home Screen - Touch-optimiertes Dark Mode Design - Quick-Select für Automaten-Werte (B16, C32, etc.) Schaltplan-Editor Verbesserungen: - Block Hover-Tooltip mit show_in_hover Feldern - Produktinfo mit Icon im Tooltip - Position und Breite in TE Neue Dateien: - pwa.php, pwa_auth.php - PWA Einstieg & Auth - ajax/pwa_api.php - PWA AJAX API - js/pwa.js, css/pwa.css - PWA App & Styles - sw.js, manifest.json - Service Worker & Manifest - img/pwa-icon-192.png, img/pwa-icon-512.png Version: 5.2.0 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1155 lines
34 KiB
JavaScript
Executable file
1155 lines
34 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');
|
|
}
|
|
|
|
cyElements.push(n);
|
|
});
|
|
}
|
|
if (elements.edges) {
|
|
elements.edges.forEach(function(e) { cyElements.push(e); });
|
|
}
|
|
|
|
// Einpassen, aber nicht über maxFitZoom hinaus zoomen
|
|
var fitWithLimit = function(cy) {
|
|
cy.fit(undefined, 50);
|
|
if (cy.zoom() > self.maxFitZoom) {
|
|
cy.zoom(self.maxFitZoom);
|
|
cy.center();
|
|
}
|
|
};
|
|
|
|
// Container-Höhe an Inhalt anpassen
|
|
var autoResizeContainer = function(cy) {
|
|
var bb = cy.elements().boundingBox();
|
|
if (!bb || bb.w === 0) return;
|
|
// Benötigte Höhe bei aktuellem Zoom + Padding
|
|
var neededHeight = (bb.h * cy.zoom()) + 100;
|
|
var container = document.getElementById(self.containerId);
|
|
var minH = 300;
|
|
var maxH = window.innerHeight * 0.8;
|
|
var newH = Math.max(minH, Math.min(maxH, neededHeight));
|
|
if (Math.abs(newH - container.clientHeight) > 20) {
|
|
container.style.height = Math.round(newH) + 'px';
|
|
cy.resize();
|
|
}
|
|
};
|
|
|
|
// Cytoscape-Instanz erstellen (ohne Layout - wird separat gestartet)
|
|
this.cy = cytoscape({
|
|
container: document.getElementById(this.containerId),
|
|
elements: cyElements,
|
|
style: this.getStylesheet(),
|
|
minZoom: 0.15,
|
|
maxZoom: 4,
|
|
userZoomingEnabled: false,
|
|
userPanningEnabled: true,
|
|
boxSelectionEnabled: false,
|
|
autoungrabify: true,
|
|
layout: { name: 'preset' }
|
|
});
|
|
|
|
// dagre-Layout NACH Instanz-Erstellung starten (self.cy ist jetzt gesetzt)
|
|
this.cy.layout({
|
|
name: 'dagre',
|
|
rankDir: 'TB',
|
|
nodeSep: 50,
|
|
rankSep: 80,
|
|
fit: false,
|
|
stop: function() {
|
|
if (self.hasPositions) {
|
|
// Gespeicherte Positionen anwenden
|
|
self.cy.nodes().forEach(function(node) {
|
|
var d = node.data();
|
|
if (d.graph_x !== null && d.graph_y !== null) {
|
|
node.position({ x: d.graph_x, y: d.graph_y });
|
|
}
|
|
});
|
|
// Container an Inhalt anpassen, dann Viewport wiederherstellen
|
|
autoResizeContainer(self.cy);
|
|
if (!self.restoreViewport()) {
|
|
fitWithLimit(self.cy);
|
|
}
|
|
} else {
|
|
// Kein gespeichertes Layout: dagre-Ergebnis einpassen
|
|
autoResizeContainer(self.cy);
|
|
fitWithLimit(self.cy);
|
|
}
|
|
}
|
|
}).run();
|
|
|
|
this.bindGraphEvents();
|
|
},
|
|
|
|
/**
|
|
* Dolibarr CSS-Variablen auslesen (Fallback für nicht-gesetzte Werte)
|
|
*/
|
|
getCssVar: function(name, fallback) {
|
|
try {
|
|
var val = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
|
return val || fallback;
|
|
} catch(e) {
|
|
return fallback;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Cytoscape Stylesheet
|
|
*/
|
|
getStylesheet: function() {
|
|
var faFont = '"Font Awesome 5 Free", "FontAwesome", sans-serif';
|
|
// Dolibarr-Schriftfarbe verwenden
|
|
var textColor = this.getCssVar('--colortext', '#dcdcdc');
|
|
|
|
return [
|
|
// Räume/Gebäude (Compound-Container)
|
|
{
|
|
selector: '.building-node',
|
|
style: {
|
|
'shape': 'roundrectangle',
|
|
'background-color': '#2a2b2d',
|
|
'background-opacity': 1,
|
|
'border-width': 2,
|
|
'border-color': function(node) { return node.data('type_color') || '#4390dc'; },
|
|
'border-style': 'dashed',
|
|
'padding': '25px',
|
|
'label': 'data(display_label)',
|
|
'font-family': faFont,
|
|
'font-weight': 'bold',
|
|
'text-valign': 'top',
|
|
'text-halign': 'center',
|
|
'font-size': '13px',
|
|
'color': textColor,
|
|
'text-margin-y': -8,
|
|
'text-background-color': '#2a2b2d',
|
|
'text-background-opacity': 0.95,
|
|
'text-background-padding': '4px',
|
|
'text-background-shape': 'roundrectangle',
|
|
'min-width': '120px',
|
|
'min-height': '60px'
|
|
}
|
|
},
|
|
// Geräte - Karten-Design mit Feldwerten (kompakt)
|
|
{
|
|
selector: '.device-node',
|
|
style: {
|
|
'shape': 'roundrectangle',
|
|
'width': 'label',
|
|
'height': 'label',
|
|
'padding': '12px',
|
|
'background-color': '#2d3d35',
|
|
'background-opacity': 0.95,
|
|
'border-width': 2,
|
|
'border-color': function(node) { return node.data('type_color') || '#5a9a6a'; },
|
|
'label': 'data(display_label)',
|
|
'font-family': faFont,
|
|
'font-weight': 900,
|
|
'text-valign': 'center',
|
|
'text-halign': 'center',
|
|
'text-justification': 'left',
|
|
'font-size': '11px',
|
|
'color': textColor,
|
|
'text-wrap': 'wrap',
|
|
'text-max-width': '240px',
|
|
'line-height': 1.5
|
|
}
|
|
},
|
|
// Kabel - Farbe aus medium_color, Fallback grün, rechtwinklig
|
|
{
|
|
selector: '.cable-edge',
|
|
style: {
|
|
'width': 3,
|
|
'line-color': function(edge) {
|
|
return edge.data('medium_color') || '#5a8a5a';
|
|
},
|
|
'target-arrow-shape': 'none',
|
|
'source-arrow-shape': 'none',
|
|
'curve-style': 'taxi',
|
|
'taxi-direction': 'downward',
|
|
'taxi-turn': '40px',
|
|
'label': 'data(label)',
|
|
'font-size': '9px',
|
|
'color': '#8a9aa8',
|
|
'text-rotation': 'autorotate',
|
|
'text-background-color': '#1a2030',
|
|
'text-background-opacity': 0.85,
|
|
'text-background-padding': '3px',
|
|
'text-background-shape': 'roundrectangle'
|
|
}
|
|
},
|
|
// Durchgeschleifte Leitungen - gestrichelt
|
|
{
|
|
selector: '.passthrough-edge',
|
|
style: {
|
|
'width': 1,
|
|
'line-color': '#505860',
|
|
'line-style': 'dashed',
|
|
'target-arrow-shape': 'none',
|
|
'source-arrow-shape': 'none',
|
|
'curve-style': 'bezier'
|
|
}
|
|
},
|
|
// Hierarchie-Kanten (Gerät→Gerät Eltern-Kind, rechtwinklig)
|
|
{
|
|
selector: '.hierarchy-edge',
|
|
style: {
|
|
'width': 1.5,
|
|
'line-color': '#6a7a8a',
|
|
'line-style': 'dotted',
|
|
'target-arrow-shape': 'none',
|
|
'source-arrow-shape': 'none',
|
|
'curve-style': 'taxi',
|
|
'taxi-direction': 'downward',
|
|
'taxi-turn': '30px',
|
|
'opacity': 0.6
|
|
}
|
|
},
|
|
// Hover
|
|
{
|
|
selector: 'node:active',
|
|
style: {
|
|
'overlay-opacity': 0.1,
|
|
'overlay-color': '#7ab0d4'
|
|
}
|
|
},
|
|
// Selektiert
|
|
{
|
|
selector: ':selected',
|
|
style: {
|
|
'border-color': '#d4944a',
|
|
'border-width': 3
|
|
}
|
|
},
|
|
// Suche: abgedunkelte Nodes
|
|
{
|
|
selector: '.search-dimmed',
|
|
style: {
|
|
'opacity': 0.2
|
|
}
|
|
},
|
|
// Suche: hervorgehobene Treffer
|
|
{
|
|
selector: '.search-highlight',
|
|
style: {
|
|
'border-color': '#e8a838',
|
|
'border-width': 3,
|
|
'opacity': 1
|
|
}
|
|
}
|
|
];
|
|
},
|
|
|
|
/**
|
|
* Kontextmenü initialisieren
|
|
*/
|
|
initContextMenu: function() {
|
|
var self = this;
|
|
this.contextMenuEl = document.getElementById('kundenkarte-graph-contextmenu');
|
|
if (!this.contextMenuEl) return;
|
|
|
|
// Klick auf Menü-Eintrag
|
|
$(this.contextMenuEl).on('click', '.ctx-item', function(e) {
|
|
e.preventDefault();
|
|
var action = $(this).data('action');
|
|
var anlageId = self.contextMenuNodeId;
|
|
if (!anlageId) return;
|
|
|
|
var baseUrl = self.pageUrl + '?id=' + self.socId + '&system=' + self.systemId;
|
|
switch (action) {
|
|
case 'view':
|
|
window.location.href = baseUrl + '&action=view&anlage_id=' + anlageId;
|
|
break;
|
|
case 'edit':
|
|
window.location.href = baseUrl + '&action=edit&anlage_id=' + anlageId;
|
|
break;
|
|
case 'copy':
|
|
window.location.href = baseUrl + '&action=copy&anlage_id=' + anlageId;
|
|
break;
|
|
case 'delete':
|
|
window.location.href = baseUrl + '&action=delete&anlage_id=' + anlageId;
|
|
break;
|
|
case 'add-child':
|
|
window.location.href = baseUrl + '&action=create&parent_id=' + anlageId;
|
|
break;
|
|
}
|
|
self.hideContextMenu();
|
|
});
|
|
|
|
// Klick außerhalb schließt Menü
|
|
$(document).on('click', function() {
|
|
self.hideContextMenu();
|
|
});
|
|
},
|
|
|
|
showContextMenu: function(node, renderedPos) {
|
|
if (!this.contextMenuEl) return;
|
|
this.contextMenuNodeId = node.data('id').replace('n_', '');
|
|
|
|
var container = document.getElementById(this.containerId);
|
|
var rect = container.getBoundingClientRect();
|
|
var x = rect.left + renderedPos.x;
|
|
var y = rect.top + renderedPos.y + window.scrollY;
|
|
|
|
// Nicht über Bildschirmrand hinaus
|
|
this.contextMenuEl.style.display = 'block';
|
|
var menuW = this.contextMenuEl.offsetWidth;
|
|
var menuH = this.contextMenuEl.offsetHeight;
|
|
if (x + menuW > window.innerWidth) x = window.innerWidth - menuW - 10;
|
|
if (y + menuH > window.scrollY + window.innerHeight) y = y - menuH;
|
|
|
|
this.contextMenuEl.style.left = x + 'px';
|
|
this.contextMenuEl.style.top = y + 'px';
|
|
},
|
|
|
|
hideContextMenu: function() {
|
|
if (this.contextMenuEl) {
|
|
this.contextMenuEl.style.display = 'none';
|
|
this.contextMenuNodeId = null;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Suchfunktion binden
|
|
*/
|
|
bindSearch: function() {
|
|
var self = this;
|
|
$(document).on('input', '#kundenkarte-graph-search', function() {
|
|
var query = $(this).val().toLowerCase().trim();
|
|
if (!self.cy) return;
|
|
|
|
if (query === '') {
|
|
// Alle Nodes wieder normal anzeigen
|
|
self.cy.nodes().removeClass('search-dimmed search-highlight');
|
|
return;
|
|
}
|
|
|
|
self.cy.nodes().forEach(function(node) {
|
|
var label = (node.data('label') || '').toLowerCase();
|
|
var typeLabel = (node.data('type_label') || '').toLowerCase();
|
|
if (label.indexOf(query) !== -1 || typeLabel.indexOf(query) !== -1) {
|
|
node.removeClass('search-dimmed').addClass('search-highlight');
|
|
} else {
|
|
node.removeClass('search-highlight').addClass('search-dimmed');
|
|
}
|
|
});
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Graph-Events binden
|
|
*/
|
|
bindGraphEvents: function() {
|
|
var self = this;
|
|
|
|
// Tap auf Geräte-Node -> Detail-Ansicht
|
|
this.cy.on('tap', 'node.device-node', function(evt) {
|
|
var node = evt.target;
|
|
var anlageId = node.data('id').replace('n_', '');
|
|
var url;
|
|
if (self.isContact && self.contactId > 0) {
|
|
url = self.moduleUrl + '/tabs/contact_anlagen.php?id=' + self.contactId
|
|
+ '&action=view&anlage_id=' + anlageId;
|
|
} else {
|
|
url = self.moduleUrl + '/tabs/anlagen.php?id=' + self.socId
|
|
+ '&action=view&anlage_id=' + anlageId;
|
|
}
|
|
window.location.href = url;
|
|
});
|
|
|
|
// Tap auf Kabel-Edge -> Verbindungs-Editor
|
|
this.cy.on('tap', 'edge.cable-edge', function(evt) {
|
|
var edge = evt.target;
|
|
var connId = edge.data('connection_id');
|
|
if (connId) {
|
|
window.location.href = self.moduleUrl + '/anlage_connection.php?id=' + connId + '&action=edit';
|
|
}
|
|
});
|
|
|
|
// Mouseover Tooltip
|
|
this.cy.on('mouseover', 'node', function(evt) {
|
|
self.showNodeTooltip(evt.target, evt.renderedPosition);
|
|
});
|
|
this.cy.on('mouseout', 'node', function() {
|
|
self.hideTooltip();
|
|
});
|
|
this.cy.on('mouseover', 'edge.cable-edge', function(evt) {
|
|
self.showEdgeTooltip(evt.target, evt.renderedPosition);
|
|
});
|
|
this.cy.on('mouseout', 'edge', function() {
|
|
self.hideTooltip();
|
|
});
|
|
|
|
// Rechtsklick → Kontextmenü
|
|
this.cy.on('cxttap', 'node', function(evt) {
|
|
evt.originalEvent.preventDefault();
|
|
self.hideTooltip();
|
|
self.showContextMenu(evt.target, evt.renderedPosition);
|
|
});
|
|
|
|
// Klick auf Canvas schließt Kontextmenü
|
|
this.cy.on('tap', function(evt) {
|
|
if (evt.target === self.cy) {
|
|
self.hideContextMenu();
|
|
}
|
|
});
|
|
|
|
// Drag: Begrenzung auf sichtbaren Bereich
|
|
this.cy.on('drag', 'node', function(evt) {
|
|
var node = evt.target;
|
|
var pos = node.position();
|
|
var ext = self.cy.extent();
|
|
// Etwas Rand lassen damit Node nicht am Rand klebt
|
|
var margin = 20;
|
|
var clampedX = Math.max(ext.x1 + margin, Math.min(ext.x2 - margin, pos.x));
|
|
var clampedY = Math.max(ext.y1 + margin, Math.min(ext.y2 - margin, pos.y));
|
|
if (clampedX !== pos.x || clampedY !== pos.y) {
|
|
node.position({ x: clampedX, y: clampedY });
|
|
}
|
|
});
|
|
|
|
// Drag: Snap-to-Grid + Position merken (nur im Bearbeitungsmodus)
|
|
this.cy.on('dragfree', 'node', function(evt) {
|
|
if (!self.editMode) return;
|
|
var node = evt.target;
|
|
var pos = node.position();
|
|
var grid = self.gridSize;
|
|
|
|
// Auf Raster einrasten
|
|
var snappedX = Math.round(pos.x / grid) * grid;
|
|
var snappedY = Math.round(pos.y / grid) * grid;
|
|
node.position({ x: snappedX, y: snappedY });
|
|
|
|
// Position zum Speichern vormerken
|
|
var anlageId = node.data('id').replace('n_', '');
|
|
self._dirtyNodes[anlageId] = { id: parseInt(anlageId), x: snappedX, y: snappedY };
|
|
|
|
// Bei Compound-Nodes auch alle Kinder speichern
|
|
var children = node.children();
|
|
if (children.length > 0) {
|
|
children.forEach(function(child) {
|
|
var childPos = child.position();
|
|
var childId = child.data('id').replace('n_', '');
|
|
self._dirtyNodes[childId] = { id: parseInt(childId), x: childPos.x, y: childPos.y };
|
|
// Rekursiv für verschachtelte Compounds
|
|
child.children().forEach(function(grandchild) {
|
|
var gcPos = grandchild.position();
|
|
var gcId = grandchild.data('id').replace('n_', '');
|
|
self._dirtyNodes[gcId] = { id: parseInt(gcId), x: gcPos.x, y: gcPos.y };
|
|
});
|
|
});
|
|
}
|
|
|
|
});
|
|
|
|
// Viewport-Änderungen speichern (Pan + Zoom)
|
|
this.cy.on('viewport', function() {
|
|
if (self._viewportTimer) clearTimeout(self._viewportTimer);
|
|
self._viewportTimer = setTimeout(function() {
|
|
self.saveViewport();
|
|
}, 300);
|
|
});
|
|
|
|
// Cursor
|
|
this.cy.on('mouseover', 'node.device-node, edge.cable-edge', function() {
|
|
document.getElementById(self.containerId).style.cursor = 'pointer';
|
|
});
|
|
this.cy.on('mouseout', 'node, edge', function() {
|
|
document.getElementById(self.containerId).style.cursor = 'default';
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Zoom-Buttons binden
|
|
*/
|
|
bindZoomButtons: function() {
|
|
var self = this;
|
|
|
|
// Mausrad-Zoom Ein/Aus
|
|
$(document).on('click', '#btn-graph-wheel-zoom', function(e) {
|
|
e.preventDefault();
|
|
self.wheelZoomEnabled = !self.wheelZoomEnabled;
|
|
if (self.cy) {
|
|
self.cy.userZoomingEnabled(self.wheelZoomEnabled);
|
|
}
|
|
$(this).toggleClass('active', self.wheelZoomEnabled);
|
|
$(this).attr('title', self.wheelZoomEnabled ? 'Mausrad-Zoom aus' : 'Mausrad-Zoom ein');
|
|
});
|
|
|
|
$(document).on('click', '#btn-graph-fit', function(e) {
|
|
e.preventDefault();
|
|
if (self.cy) {
|
|
self.cy.zoom(self.maxFitZoom);
|
|
self.cy.center();
|
|
}
|
|
});
|
|
$(document).on('click', '#btn-graph-zoom-in', function(e) {
|
|
e.preventDefault();
|
|
if (self.cy) {
|
|
self.cy.zoom({
|
|
level: self.cy.zoom() * 1.3,
|
|
renderedPosition: { x: self.cy.width() / 2, y: self.cy.height() / 2 }
|
|
});
|
|
}
|
|
});
|
|
$(document).on('click', '#btn-graph-zoom-out', function(e) {
|
|
e.preventDefault();
|
|
if (self.cy) {
|
|
self.cy.zoom({
|
|
level: self.cy.zoom() / 1.3,
|
|
renderedPosition: { x: self.cy.width() / 2, y: self.cy.height() / 2 }
|
|
});
|
|
}
|
|
});
|
|
|
|
// Layout zurücksetzen
|
|
$(document).on('click', '#btn-graph-reset-layout', function(e) {
|
|
e.preventDefault();
|
|
self.resetLayout();
|
|
});
|
|
|
|
// Bearbeitungsmodus: Anordnen ein/aus
|
|
$(document).on('click', '#btn-graph-edit-mode', function(e) {
|
|
e.preventDefault();
|
|
self.enterEditMode();
|
|
});
|
|
|
|
// Bearbeitungsmodus: Speichern
|
|
$(document).on('click', '#btn-graph-save-positions', function(e) {
|
|
e.preventDefault();
|
|
self.savePositions();
|
|
self.exitEditMode();
|
|
});
|
|
|
|
// Bearbeitungsmodus: Abbrechen
|
|
$(document).on('click', '#btn-graph-cancel-edit', function(e) {
|
|
e.preventDefault();
|
|
self.cancelEditMode();
|
|
});
|
|
|
|
// PNG-Export
|
|
$(document).on('click', '#btn-graph-export-png', function(e) {
|
|
e.preventDefault();
|
|
if (!self.cy) return;
|
|
var png64 = self.cy.png({
|
|
output: 'base64uri',
|
|
bg: getComputedStyle(document.documentElement).getPropertyValue('--colorbackbody').trim() || '#1d1e20',
|
|
full: true,
|
|
scale: 2
|
|
});
|
|
var link = document.createElement('a');
|
|
link.download = 'graph_export.png';
|
|
link.href = png64;
|
|
link.click();
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Bearbeitungsmodus aktivieren - Nodes werden verschiebbar
|
|
*/
|
|
enterEditMode: function() {
|
|
if (!this.cy || this.editMode) return;
|
|
this.editMode = true;
|
|
this._dirtyNodes = {};
|
|
|
|
// Aktuelle Positionen sichern (für Abbrechen)
|
|
this._savedPositions = {};
|
|
var self = this;
|
|
this.cy.nodes().forEach(function(node) {
|
|
var pos = node.position();
|
|
self._savedPositions[node.data('id')] = { x: pos.x, y: pos.y };
|
|
});
|
|
|
|
// Nodes verschiebbar machen
|
|
this.cy.autoungrabify(false);
|
|
|
|
// Buttons umschalten
|
|
$('#btn-graph-edit-mode').hide();
|
|
$('#btn-graph-save-positions, #btn-graph-cancel-edit').show();
|
|
|
|
// Visuelles Feedback: Container-Rahmen
|
|
$('#' + this.containerId).addClass('graph-edit-mode');
|
|
},
|
|
|
|
/**
|
|
* Bearbeitungsmodus beenden (nach Speichern)
|
|
*/
|
|
exitEditMode: function() {
|
|
if (!this.cy) return;
|
|
this.editMode = false;
|
|
this._savedPositions = {};
|
|
|
|
// Nodes wieder sperren
|
|
this.cy.autoungrabify(true);
|
|
|
|
// Buttons zurückschalten
|
|
$('#btn-graph-edit-mode').show();
|
|
$('#btn-graph-save-positions, #btn-graph-cancel-edit').hide();
|
|
|
|
// Visuelles Feedback entfernen
|
|
$('#' + this.containerId).removeClass('graph-edit-mode');
|
|
},
|
|
|
|
/**
|
|
* Bearbeitungsmodus abbrechen - Positionen zurücksetzen
|
|
*/
|
|
cancelEditMode: function() {
|
|
if (!this.cy) return;
|
|
|
|
// Positionen zurücksetzen
|
|
var self = this;
|
|
this.cy.nodes().forEach(function(node) {
|
|
var saved = self._savedPositions[node.data('id')];
|
|
if (saved) {
|
|
node.position({ x: saved.x, y: saved.y });
|
|
}
|
|
});
|
|
|
|
this._dirtyNodes = {};
|
|
this.exitEditMode();
|
|
},
|
|
|
|
/**
|
|
* Geänderte Positionen an Server senden
|
|
*/
|
|
savePositions: function() {
|
|
var positions = [];
|
|
for (var id in this._dirtyNodes) {
|
|
positions.push(this._dirtyNodes[id]);
|
|
}
|
|
if (positions.length === 0) return;
|
|
|
|
// Dirty-Liste leeren
|
|
this._dirtyNodes = {};
|
|
|
|
$.ajax({
|
|
url: this.saveUrl + '?action=save',
|
|
type: 'POST',
|
|
contentType: 'application/json',
|
|
data: JSON.stringify({ positions: positions }),
|
|
dataType: 'json',
|
|
success: function(resp) {
|
|
if (!resp.success) {
|
|
console.error('Graph-Positionen speichern fehlgeschlagen:', resp.error);
|
|
}
|
|
},
|
|
error: function(xhr, status, err) {
|
|
console.error('Graph-Positionen Netzwerkfehler:', status, err);
|
|
}
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Layout zurücksetzen: Positionen löschen + dagre neu berechnen
|
|
*/
|
|
resetLayout: function() {
|
|
if (!this.cy) return;
|
|
var self = this;
|
|
|
|
// Positionen in DB + Viewport in localStorage löschen
|
|
$.ajax({
|
|
url: this.saveUrl + '?action=reset&socid=' + this.socId +
|
|
(this.contactId > 0 ? '&contactid=' + this.contactId : ''),
|
|
type: 'POST',
|
|
dataType: 'json'
|
|
});
|
|
try { localStorage.removeItem(this._viewportKey()); } catch(e) {}
|
|
|
|
// dagre Layout neu berechnen
|
|
this.hasPositions = false;
|
|
this._dirtyNodes = {};
|
|
var cy = this.cy;
|
|
var maxZoom = this.maxFitZoom;
|
|
cy.layout({
|
|
name: 'dagre',
|
|
rankDir: 'TB',
|
|
nodeSep: 50,
|
|
rankSep: 80,
|
|
fit: false,
|
|
animate: true,
|
|
animationDuration: 400,
|
|
stop: function() {
|
|
// Container an neues Layout anpassen
|
|
var bb = cy.elements().boundingBox();
|
|
if (bb && bb.h > 0) {
|
|
var neededH = (bb.h * maxZoom) + 100;
|
|
var container = document.getElementById(self.containerId);
|
|
var minH = 300;
|
|
var maxH = window.innerHeight * 0.8;
|
|
var newH = Math.max(minH, Math.min(maxH, neededH));
|
|
container.style.height = Math.round(newH) + 'px';
|
|
cy.resize();
|
|
}
|
|
cy.fit(undefined, 50);
|
|
if (cy.zoom() > maxZoom) {
|
|
cy.zoom(maxZoom);
|
|
cy.center();
|
|
}
|
|
}
|
|
}).run();
|
|
},
|
|
|
|
/**
|
|
* Node-Tooltip
|
|
*/
|
|
showNodeTooltip: function(node, position) {
|
|
var data = node.data();
|
|
|
|
// Header: Typ-Farbe als Akzentlinie
|
|
var accentColor = data.type_color || '#5a9a6a';
|
|
var html = '<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
|
|
});
|
|
}
|
|
});
|
|
|
|
})();
|