Version 5.0.0 - Cytoscape.js Graph-Ansicht & Verbindungsformular
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>
This commit is contained in:
parent
0a151270ec
commit
840c0132c3
20 changed files with 15021 additions and 127 deletions
50
ChangeLog.md
50
ChangeLog.md
|
|
@ -1,5 +1,55 @@
|
|||
# CHANGELOG MODULE KUNDENKARTE FOR [DOLIBARR ERP CRM](https://www.dolibarr.org)
|
||||
|
||||
## 5.0.0 (2026-02)
|
||||
|
||||
### Neue Features
|
||||
- **Cytoscape.js Graph-Ansicht**: Neue interaktive Netzwerk-Visualisierung
|
||||
- Raeume als Compound-Container, Geraete als Nodes darin
|
||||
- Kabelverbindungen als sichtbare Edges (auch raumuebergreifend)
|
||||
- Durchgeschleifte Leitungen als gestrichelte Linien
|
||||
- Zwei Layout-Modi per Knopfdruck:
|
||||
- **Raeumlich** (cose-bilkent): Geraete gruppiert nach Raeumen
|
||||
- **Technisch** (dagre): Hierarchischer Stromfluss top-down
|
||||
- Zoom/Pan/Fit-Controls, Mausrad-Zoom Toggle
|
||||
- Kabeltyp-Legende mit Farben
|
||||
- Node-Positionen speicherbar (Drag&Drop → AJAX-Speicherung)
|
||||
- Viewport-Persistenz (Zoom/Pan bleibt beim Seitenwechsel)
|
||||
- Klick auf Node/Edge oeffnet Detail-/Bearbeitungsseite
|
||||
- Admin-Setting: Ansichtsmodus (Baum/Graph) in Setup waehlbar
|
||||
|
||||
- **Verbindungsformular verbessert**
|
||||
- Select-Dropdowns zeigen nur Geraete (keine Gebaeude/Raeume)
|
||||
- Icons (FontAwesome) in Select-Optionen via select2
|
||||
- Gebaeude-Pfad als Kontext (z.B. "EG > Zahlerschrank")
|
||||
- Systemuebergreifende Geraete-Auswahl (kein Systemfilter)
|
||||
|
||||
### Neue Dateien
|
||||
- `js/kundenkarte_cytoscape.js` - Graph-Namespace (~750 Zeilen)
|
||||
- `css/kundenkarte_cytoscape.css` - Graph-Styles mit Dark Mode
|
||||
- `ajax/graph_data.php` - AJAX: Baum+Verbindungen → Cytoscape-Format
|
||||
- `ajax/graph_save_positions.php` - AJAX: Node-Positionen speichern
|
||||
- `js/cytoscape.min.js`, `js/dagre.min.js`, `js/cytoscape-dagre.js`, `js/cytoscape-cose-bilkent.js` - Bibliotheken
|
||||
|
||||
### Bugfixes
|
||||
- **Verbindung hinzufuegen**: Formular zeigte "Feld erforderlich"-Fehler beim Oeffnen
|
||||
- Ursache: `action=create` in URL triggerte Handler vor Formular-Anzeige
|
||||
- Fix: Korrekte Dolibarr-Konvention (create=Formular, add=Verarbeitung)
|
||||
- **Leere Dropdowns**: Quelle/Ziel-Auswahl war leer wenn System keine Elemente hatte
|
||||
- Fix: Kein System-Filter mehr (Kabel koennen systemuebergreifend sein)
|
||||
- **Kontakt-Redirect**: Nach Verbindung-Bearbeiten landete man auf Kundenansicht statt Kontaktansicht
|
||||
- Fix: `contactid` wird jetzt in allen Edit-URLs mitgegeben
|
||||
- **Kontakt-Anlagen**: Auf Stand von Kunden-Anlagen gebracht
|
||||
- tree_display_mode, badge_color, Schaltplan-Editor, Drag&Drop Upload
|
||||
|
||||
### Datenbank-Aenderungen
|
||||
- Neue Spalten `graph_x`, `graph_y` in `llx_kundenkarte_anlage` (Node-Positionen)
|
||||
- Neue Spalte `fk_building_node` in `llx_kundenkarte_anlage` (vorbereitet fuer Phase 2)
|
||||
|
||||
### Admin-Settings
|
||||
- `KUNDENKARTE_DEFAULT_VIEW`: `tree` (Standard) oder `graph`
|
||||
|
||||
---
|
||||
|
||||
## 4.0.1 (2026-02)
|
||||
|
||||
### Neue Features
|
||||
|
|
|
|||
|
|
@ -122,6 +122,9 @@ if ($action == 'update') {
|
|||
dolibarr_set_const($db, 'KUNDENKARTE_PDF_FONT_CONTENT', GETPOSTINT('KUNDENKARTE_PDF_FONT_CONTENT'), 'chaine', 0, '', $conf->entity);
|
||||
dolibarr_set_const($db, 'KUNDENKARTE_PDF_FONT_FIELDS', GETPOSTINT('KUNDENKARTE_PDF_FONT_FIELDS'), 'chaine', 0, '', $conf->entity);
|
||||
|
||||
// View mode
|
||||
dolibarr_set_const($db, 'KUNDENKARTE_DEFAULT_VIEW', GETPOST('KUNDENKARTE_DEFAULT_VIEW', 'aZ09'), 'chaine', 0, '', $conf->entity);
|
||||
|
||||
// Tree display settings
|
||||
dolibarr_set_const($db, 'KUNDENKARTE_TREE_INFO_DISPLAY', GETPOST('KUNDENKARTE_TREE_INFO_DISPLAY', 'aZ09'), 'chaine', 0, '', $conf->entity);
|
||||
dolibarr_set_const($db, 'KUNDENKARTE_TREE_BADGE_COLOR', GETPOST('KUNDENKARTE_TREE_BADGE_COLOR', 'alphanohtml'), 'chaine', 0, '', $conf->entity);
|
||||
|
|
@ -192,6 +195,18 @@ print $form->selectarray('KUNDENKARTE_DEFAULT_ORDER_TYPE', $orderTypes, getDolGl
|
|||
print '</td>';
|
||||
print '</tr>';
|
||||
|
||||
// Default View Mode for Anlagen
|
||||
print '<tr class="oddeven">';
|
||||
print '<td>'.$langs->trans("DefaultViewMode").'</td>';
|
||||
print '<td>';
|
||||
$viewModes = array(
|
||||
'tree' => $langs->trans("ViewModeTree"),
|
||||
'graph' => $langs->trans("ViewModeGraph"),
|
||||
);
|
||||
print $form->selectarray('KUNDENKARTE_DEFAULT_VIEW', $viewModes, getDolGlobalString('KUNDENKARTE_DEFAULT_VIEW', 'tree'));
|
||||
print '</td>';
|
||||
print '</tr>';
|
||||
|
||||
print '</table>';
|
||||
|
||||
// Tree Display Settings
|
||||
|
|
|
|||
242
ajax/graph_data.php
Normal file
242
ajax/graph_data.php
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
<?php
|
||||
/* Copyright (C) 2026 Alles Watt lauft
|
||||
*
|
||||
* AJAX-Endpunkt: Liefert Anlagen als Cytoscape.js Graph
|
||||
* Gebäude-Typen (type_system_code=GLOBAL) = Räume (Compound-Container)
|
||||
* Geräte-Typen = Geräte (Nodes innerhalb der Räume)
|
||||
* Hierarchie über fk_parent (wie im Baum)
|
||||
* Connections = Kabel-Edges zwischen Geräten
|
||||
*/
|
||||
|
||||
if (!defined('NOTOKENRENEWAL')) define('NOTOKENRENEWAL', '1');
|
||||
if (!defined('NOREQUIREMENU')) define('NOREQUIREMENU', '1');
|
||||
if (!defined('NOREQUIREHTML')) define('NOREQUIREHTML', '1');
|
||||
if (!defined('NOREQUIREAJAX')) define('NOREQUIREAJAX', '1');
|
||||
|
||||
$res = 0;
|
||||
if (!$res && file_exists("../../main.inc.php")) $res = @include "../../main.inc.php";
|
||||
if (!$res && file_exists("../../../main.inc.php")) $res = @include "../../../main.inc.php";
|
||||
if (!$res) die("Include of main fails");
|
||||
|
||||
dol_include_once('/kundenkarte/class/anlageconnection.class.php');
|
||||
|
||||
header('Content-Type: application/json; charset=UTF-8');
|
||||
|
||||
$langs->loadLangs(array('kundenkarte@kundenkarte'));
|
||||
|
||||
$socId = GETPOSTINT('socid');
|
||||
$contactId = GETPOSTINT('contactid');
|
||||
|
||||
$response = array('success' => false, 'error' => '');
|
||||
|
||||
// Berechtigungsprüfung
|
||||
if (!$user->hasRight('kundenkarte', 'read')) {
|
||||
$response['error'] = $langs->trans('ErrorPermissionDenied');
|
||||
echo json_encode($response);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($socId <= 0) {
|
||||
$response['error'] = 'Missing socid';
|
||||
echo json_encode($response);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Feld-Metadaten laden (field_code → Label, Display-Modus, Badge-Farbe pro Typ)
|
||||
$fieldMeta = array(); // [fk_anlage_type][field_code] = {label, display_mode, badge_color}
|
||||
$sqlFields = "SELECT fk_anlage_type, field_code, field_label, field_type, tree_display_mode, badge_color";
|
||||
$sqlFields .= " FROM ".MAIN_DB_PREFIX."kundenkarte_anlage_type_field WHERE active = 1 ORDER BY position";
|
||||
$resFields = $db->query($sqlFields);
|
||||
if ($resFields) {
|
||||
while ($fObj = $db->fetch_object($resFields)) {
|
||||
if ($fObj->field_type === 'header') continue;
|
||||
$fieldMeta[(int)$fObj->fk_anlage_type][$fObj->field_code] = array(
|
||||
'label' => $fObj->field_label,
|
||||
'display' => $fObj->tree_display_mode ?: 'badge',
|
||||
'color' => $fObj->badge_color ?: '',
|
||||
'type' => $fObj->field_type,
|
||||
);
|
||||
}
|
||||
$db->free($resFields);
|
||||
}
|
||||
|
||||
// Elemente laden - OHNE GLOBAL-System (das ist nur die separate Gebäudestruktur)
|
||||
// Gebäude/Räume werden über den Typ erkannt (type_system_code = GLOBAL)
|
||||
// Hierarchie kommt aus fk_parent (wie im Baum)
|
||||
$sql = "SELECT a.rowid, a.label, a.fk_parent, a.fk_system, a.fk_anlage_type,";
|
||||
$sql .= " a.field_values, a.fk_contact, a.graph_x, a.graph_y,";
|
||||
$sql .= " t.label as type_label, t.picto as type_picto,";
|
||||
$sql .= " s.code as system_code, s.label as system_label,";
|
||||
$sql .= " ts.code as type_system_code,";
|
||||
$sql .= " (SELECT COUNT(*) FROM ".MAIN_DB_PREFIX."kundenkarte_anlage_files f WHERE f.fk_anlage = a.rowid AND f.file_type IN ('image/jpeg','image/png','image/gif','image/webp')) as image_count,";
|
||||
$sql .= " (SELECT COUNT(*) FROM ".MAIN_DB_PREFIX."kundenkarte_anlage_files f WHERE f.fk_anlage = a.rowid AND f.file_type NOT IN ('image/jpeg','image/png','image/gif','image/webp')) as doc_count";
|
||||
$sql .= " FROM ".MAIN_DB_PREFIX."kundenkarte_anlage as a";
|
||||
$sql .= " LEFT JOIN ".MAIN_DB_PREFIX."kundenkarte_anlage_type as t ON a.fk_anlage_type = t.rowid";
|
||||
$sql .= " LEFT JOIN ".MAIN_DB_PREFIX."c_kundenkarte_anlage_system as s ON a.fk_system = s.rowid";
|
||||
$sql .= " LEFT JOIN ".MAIN_DB_PREFIX."c_kundenkarte_anlage_system as ts ON t.fk_system = ts.rowid";
|
||||
$sql .= " WHERE a.fk_soc = ".(int)$socId;
|
||||
$sql .= " AND s.code != 'GLOBAL'";
|
||||
if ($contactId > 0) {
|
||||
$sql .= " AND a.fk_contact = ".(int)$contactId;
|
||||
}
|
||||
$sql .= " AND a.status = 1";
|
||||
$sql .= " ORDER BY a.fk_parent, a.rang, a.rowid";
|
||||
|
||||
$elements = array('nodes' => array(), 'edges' => array());
|
||||
$nodeIds = array();
|
||||
|
||||
$resql = $db->query($sql);
|
||||
if ($resql) {
|
||||
while ($obj = $db->fetch_object($resql)) {
|
||||
// Typ bestimmt ob Raum oder Gerät (GLOBAL-Typ = Gebäude/Raum)
|
||||
$isBuilding = (!empty($obj->type_system_code) && $obj->type_system_code === 'GLOBAL');
|
||||
|
||||
$nodeId = 'n_'.$obj->rowid;
|
||||
$nodeIds[$obj->rowid] = true;
|
||||
|
||||
$nodeData = array(
|
||||
'id' => $nodeId,
|
||||
'label' => $obj->label,
|
||||
'type_label' => $obj->type_label ?: '',
|
||||
'type_picto' => $obj->type_picto ?: '',
|
||||
'system_code' => $obj->system_code ?: '',
|
||||
'system_label' => $obj->system_label ?: '',
|
||||
'fk_parent' => (int) $obj->fk_parent,
|
||||
'fk_anlage_type' => (int) $obj->fk_anlage_type,
|
||||
'is_building' => $isBuilding,
|
||||
'image_count' => (int) $obj->image_count,
|
||||
'doc_count' => (int) $obj->doc_count,
|
||||
'graph_x' => $obj->graph_x !== null ? (float) $obj->graph_x : null,
|
||||
'graph_y' => $obj->graph_y !== null ? (float) $obj->graph_y : null,
|
||||
);
|
||||
|
||||
// Feldwerte mit Metadaten (Label, Display-Modus, Badge-Farbe)
|
||||
if (!empty($obj->field_values)) {
|
||||
$rawValues = json_decode($obj->field_values, true);
|
||||
if (is_array($rawValues) && !empty($rawValues)) {
|
||||
$typeId = (int) $obj->fk_anlage_type;
|
||||
$meta = isset($fieldMeta[$typeId]) ? $fieldMeta[$typeId] : array();
|
||||
$fields = array();
|
||||
foreach ($rawValues as $code => $val) {
|
||||
if ($val === '' || $val === null) continue;
|
||||
$fm = isset($meta[$code]) ? $meta[$code] : array();
|
||||
$display = isset($fm['display']) ? $fm['display'] : 'badge';
|
||||
// Versteckte Felder überspringen
|
||||
if ($display === 'none') continue;
|
||||
$label = isset($fm['label']) ? $fm['label'] : $code;
|
||||
// Checkbox-Werte anpassen
|
||||
if (isset($fm['type']) && $fm['type'] === 'checkbox') {
|
||||
$val = $val ? '1' : '0';
|
||||
}
|
||||
$fields[] = array(
|
||||
'label' => $label,
|
||||
'value' => $val,
|
||||
'display' => $display,
|
||||
'color' => isset($fm['color']) ? $fm['color'] : '',
|
||||
);
|
||||
}
|
||||
$nodeData['fields'] = $fields;
|
||||
}
|
||||
}
|
||||
|
||||
// Compound-Parent aus fk_parent (wie im Baum)
|
||||
if ($obj->fk_parent > 0) {
|
||||
$nodeData['parent'] = 'n_'.$obj->fk_parent;
|
||||
}
|
||||
|
||||
$elements['nodes'][] = array(
|
||||
'data' => $nodeData,
|
||||
'classes' => $isBuilding ? 'building-node' : 'device-node'
|
||||
);
|
||||
}
|
||||
$db->free($resql);
|
||||
}
|
||||
|
||||
// Verbindungen laden
|
||||
$connObj = new AnlageConnection($db);
|
||||
$connections = $connObj->fetchBySociete($socId, 0);
|
||||
|
||||
// Verwendete Kabeltypen für die Legende sammeln
|
||||
$usedCableTypes = array();
|
||||
|
||||
foreach ($connections as $conn) {
|
||||
// Nur Edges für tatsächlich geladene Nodes
|
||||
if (!isset($nodeIds[$conn->fk_source]) || !isset($nodeIds[$conn->fk_target])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$isPassthrough = empty($conn->label) && empty($conn->medium_type_label) && empty($conn->medium_type_text) && empty($conn->medium_spec);
|
||||
|
||||
// Edge-Label zusammenbauen
|
||||
$edgeLabel = '';
|
||||
$mediumType = !empty($conn->medium_type_label) ? $conn->medium_type_label : $conn->medium_type_text;
|
||||
if (!empty($mediumType)) {
|
||||
$edgeLabel = $mediumType;
|
||||
if (!empty($conn->medium_spec)) {
|
||||
$edgeLabel .= ' '.$conn->medium_spec;
|
||||
}
|
||||
if (!empty($conn->medium_length)) {
|
||||
$edgeLabel .= ', '.$conn->medium_length;
|
||||
}
|
||||
}
|
||||
|
||||
// Farbe: aus Connection oder aus Medium-Type
|
||||
$color = $conn->medium_color;
|
||||
if (empty($color) && !empty($conn->fk_medium_type)) {
|
||||
// Farbe aus Medium-Type-Tabelle holen
|
||||
$sqlColor = "SELECT color, label_short FROM ".MAIN_DB_PREFIX."kundenkarte_medium_type WHERE rowid = ".(int)$conn->fk_medium_type;
|
||||
$resColor = $db->query($sqlColor);
|
||||
if ($resColor && $mtObj = $db->fetch_object($resColor)) {
|
||||
$color = $mtObj->color;
|
||||
}
|
||||
}
|
||||
|
||||
// Kabeltyp für Legende merken
|
||||
if (!$isPassthrough && !empty($mediumType)) {
|
||||
$typeKey = $mediumType;
|
||||
if (!isset($usedCableTypes[$typeKey])) {
|
||||
$usedCableTypes[$typeKey] = array(
|
||||
'label' => $mediumType,
|
||||
'color' => $color ?: '#5a8a5a',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$elements['edges'][] = array(
|
||||
'data' => array(
|
||||
'id' => 'conn_'.$conn->id,
|
||||
'source' => 'n_'.$conn->fk_source,
|
||||
'target' => 'n_'.$conn->fk_target,
|
||||
'label' => $edgeLabel,
|
||||
'medium_type' => $mediumType,
|
||||
'medium_spec' => $conn->medium_spec,
|
||||
'medium_length' => $conn->medium_length,
|
||||
'medium_color' => $color,
|
||||
'connection_id' => (int) $conn->id,
|
||||
'is_passthrough' => $isPassthrough,
|
||||
),
|
||||
'classes' => $isPassthrough ? 'passthrough-edge' : 'cable-edge'
|
||||
);
|
||||
}
|
||||
|
||||
// Prüfen ob gespeicherte Positionen vorhanden sind
|
||||
$hasPositions = false;
|
||||
foreach ($elements['nodes'] as $node) {
|
||||
if ($node['data']['graph_x'] !== null) {
|
||||
$hasPositions = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$response['success'] = true;
|
||||
$response['elements'] = $elements;
|
||||
$response['cable_types'] = array_values($usedCableTypes);
|
||||
$response['has_positions'] = $hasPositions;
|
||||
$response['meta'] = array(
|
||||
'socid' => $socId,
|
||||
'contactid' => $contactId,
|
||||
'total_nodes' => count($elements['nodes']),
|
||||
'total_connections' => count($connections),
|
||||
);
|
||||
|
||||
echo json_encode($response);
|
||||
93
ajax/graph_save_positions.php
Normal file
93
ajax/graph_save_positions.php
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
<?php
|
||||
/* Copyright (C) 2026 Alles Watt lauft
|
||||
*
|
||||
* AJAX-Endpunkt: Graph-Positionen speichern
|
||||
* Speichert x/y-Koordinaten der Nodes nach Drag&Drop
|
||||
*/
|
||||
|
||||
if (!defined('NOTOKENRENEWAL')) define('NOTOKENRENEWAL', '1');
|
||||
if (!defined('NOREQUIREMENU')) define('NOREQUIREMENU', '1');
|
||||
if (!defined('NOREQUIREHTML')) define('NOREQUIREHTML', '1');
|
||||
if (!defined('NOREQUIREAJAX')) define('NOREQUIREAJAX', '1');
|
||||
if (!defined('NOCSRFCHECK')) define('NOCSRFCHECK', '1');
|
||||
|
||||
$res = 0;
|
||||
if (!$res && file_exists("../../main.inc.php")) $res = @include "../../main.inc.php";
|
||||
if (!$res && file_exists("../../../main.inc.php")) $res = @include "../../../main.inc.php";
|
||||
if (!$res) die("Include of main fails");
|
||||
|
||||
header('Content-Type: application/json; charset=UTF-8');
|
||||
|
||||
$response = array('success' => false, 'error' => '');
|
||||
|
||||
// Berechtigungsprüfung
|
||||
if (!$user->hasRight('kundenkarte', 'write')) {
|
||||
$response['error'] = 'Keine Berechtigung';
|
||||
echo json_encode($response);
|
||||
exit;
|
||||
}
|
||||
|
||||
$action = GETPOST('action', 'aZ');
|
||||
|
||||
if ($action === 'save') {
|
||||
// Positionen als JSON-Array: [{id: 123, x: 45.6, y: 78.9}, ...]
|
||||
$rawInput = file_get_contents('php://input');
|
||||
$input = json_decode($rawInput, true);
|
||||
|
||||
if (!is_array($input) || empty($input['positions'])) {
|
||||
$response['error'] = 'Keine Positionen übergeben';
|
||||
echo json_encode($response);
|
||||
exit;
|
||||
}
|
||||
|
||||
$saved = 0;
|
||||
$db->begin();
|
||||
|
||||
foreach ($input['positions'] as $pos) {
|
||||
$anlageId = (int) ($pos['id'] ?? 0);
|
||||
$x = (float) ($pos['x'] ?? 0);
|
||||
$y = (float) ($pos['y'] ?? 0);
|
||||
if ($anlageId <= 0) continue;
|
||||
|
||||
$sql = "UPDATE ".MAIN_DB_PREFIX."kundenkarte_anlage";
|
||||
$sql .= " SET graph_x = ".$x.", graph_y = ".$y;
|
||||
$sql .= " WHERE rowid = ".$anlageId;
|
||||
if ($db->query($sql)) {
|
||||
$saved++;
|
||||
}
|
||||
}
|
||||
|
||||
$db->commit();
|
||||
$response['success'] = true;
|
||||
$response['saved'] = $saved;
|
||||
|
||||
} elseif ($action === 'reset') {
|
||||
// Alle Positionen für einen Kunden zurücksetzen
|
||||
$socId = GETPOSTINT('socid');
|
||||
$contactId = GETPOSTINT('contactid');
|
||||
|
||||
if ($socId <= 0) {
|
||||
$response['error'] = 'Fehlende socid';
|
||||
echo json_encode($response);
|
||||
exit;
|
||||
}
|
||||
|
||||
$sql = "UPDATE ".MAIN_DB_PREFIX."kundenkarte_anlage";
|
||||
$sql .= " SET graph_x = NULL, graph_y = NULL";
|
||||
$sql .= " WHERE fk_soc = ".(int)$socId;
|
||||
if ($contactId > 0) {
|
||||
$sql .= " AND fk_contact = ".(int)$contactId;
|
||||
}
|
||||
|
||||
if ($db->query($sql)) {
|
||||
$response['success'] = true;
|
||||
$response['reset'] = $db->affected_rows;
|
||||
} else {
|
||||
$response['error'] = 'Datenbankfehler';
|
||||
}
|
||||
|
||||
} else {
|
||||
$response['error'] = 'Unbekannte Aktion';
|
||||
}
|
||||
|
||||
echo json_encode($response);
|
||||
|
|
@ -18,6 +18,7 @@ $langs->loadLangs(array('kundenkarte@kundenkarte'));
|
|||
|
||||
$id = GETPOSTINT('id');
|
||||
$socId = GETPOSTINT('socid');
|
||||
$contactId = GETPOSTINT('contactid');
|
||||
$systemId = GETPOSTINT('system_id');
|
||||
$sourceId = GETPOSTINT('source_id');
|
||||
$action = GETPOST('action', 'aZ09');
|
||||
|
|
@ -49,6 +50,13 @@ if ($id > 0) {
|
|||
}
|
||||
}
|
||||
|
||||
// Redirect-URL: zurück zur Kontakt- oder Kunden-Anlagenansicht
|
||||
if ($contactId > 0) {
|
||||
$backUrl = dol_buildpath('/kundenkarte/tabs/contact_anlagen.php', 1).'?id='.$contactId.'&system='.$systemId;
|
||||
} else {
|
||||
$backUrl = dol_buildpath('/kundenkarte/tabs/anlagen.php', 1).'?id='.$socId.'&system='.$systemId;
|
||||
}
|
||||
|
||||
/*
|
||||
* Actions
|
||||
*/
|
||||
|
|
@ -71,7 +79,7 @@ if ($action == 'update' && $user->hasRight('kundenkarte', 'write')) {
|
|||
$result = $connection->update($user);
|
||||
if ($result > 0) {
|
||||
setEventMessages($langs->trans('RecordSaved'), null, 'mesgs');
|
||||
header('Location: '.dol_buildpath('/kundenkarte/tabs/anlagen.php', 1).'?id='.$socId.'&system='.$systemId);
|
||||
header('Location: '.$backUrl);
|
||||
exit;
|
||||
} else {
|
||||
setEventMessages($connection->error, null, 'errors');
|
||||
|
|
@ -79,7 +87,7 @@ if ($action == 'update' && $user->hasRight('kundenkarte', 'write')) {
|
|||
}
|
||||
}
|
||||
|
||||
if ($action == 'create' && $user->hasRight('kundenkarte', 'write')) {
|
||||
if ($action == 'add' && $user->hasRight('kundenkarte', 'write')) {
|
||||
$connection->fk_source = GETPOSTINT('fk_source');
|
||||
$connection->fk_target = GETPOSTINT('fk_target');
|
||||
$connection->label = GETPOST('label', 'alphanohtml');
|
||||
|
|
@ -98,7 +106,7 @@ if ($action == 'create' && $user->hasRight('kundenkarte', 'write')) {
|
|||
$result = $connection->create($user);
|
||||
if ($result > 0) {
|
||||
setEventMessages($langs->trans('RecordSaved'), null, 'mesgs');
|
||||
header('Location: '.dol_buildpath('/kundenkarte/tabs/anlagen.php', 1).'?id='.$socId.'&system='.$systemId);
|
||||
header('Location: '.$backUrl);
|
||||
exit;
|
||||
} else {
|
||||
setEventMessages($connection->error, null, 'errors');
|
||||
|
|
@ -110,7 +118,7 @@ if ($action == 'delete' && $user->hasRight('kundenkarte', 'write')) {
|
|||
$result = $connection->delete($user);
|
||||
if ($result > 0) {
|
||||
setEventMessages($langs->trans('RecordDeleted'), null, 'mesgs');
|
||||
header('Location: '.dol_buildpath('/kundenkarte/tabs/anlagen.php', 1).'?id='.$socId.'&system='.$systemId);
|
||||
header('Location: '.$backUrl);
|
||||
exit;
|
||||
} else {
|
||||
setEventMessages($connection->error, null, 'errors');
|
||||
|
|
@ -124,16 +132,56 @@ if ($action == 'delete' && $user->hasRight('kundenkarte', 'write')) {
|
|||
$title = $id > 0 ? 'Verbindung bearbeiten' : 'Neue Verbindung';
|
||||
llxHeader('', $title);
|
||||
|
||||
// Load anlagen for dropdowns
|
||||
// Gebäude-Typ-IDs ermitteln (Verbindungen nur zwischen Geräten, nicht Gebäuden)
|
||||
$buildingTypeIds = array();
|
||||
$sqlBt = "SELECT t.rowid FROM ".MAIN_DB_PREFIX."kundenkarte_anlage_type t";
|
||||
$sqlBt .= " LEFT JOIN ".MAIN_DB_PREFIX."c_kundenkarte_anlage_system ts ON t.fk_system = ts.rowid";
|
||||
$sqlBt .= " WHERE ts.code = 'GLOBAL'";
|
||||
$resBt = $db->query($sqlBt);
|
||||
if ($resBt) {
|
||||
while ($btObj = $db->fetch_object($resBt)) {
|
||||
$buildingTypeIds[] = (int) $btObj->rowid;
|
||||
}
|
||||
}
|
||||
|
||||
// Alle Elemente für Dropdowns laden (OHNE System-Filter, da Kabel systemübergreifend sein können)
|
||||
$anlagenList = array();
|
||||
if ($socId > 0) {
|
||||
$tree = $anlage->fetchTree($socId, $systemId);
|
||||
// Flatten tree
|
||||
$flattenTree = function($nodes, $prefix = '') use (&$flattenTree, &$anlagenList) {
|
||||
if ($contactId > 0) {
|
||||
$tree = $anlage->fetchTreeByContact($socId, $contactId, 0);
|
||||
} else {
|
||||
$tree = $anlage->fetchTree($socId, 0);
|
||||
}
|
||||
// Baum flach machen - nur Geräte, Gebäude als Pfad-Kontext
|
||||
$flattenTree = function($nodes, $path = '') use (&$flattenTree, &$anlagenList, &$buildingTypeIds) {
|
||||
foreach ($nodes as $node) {
|
||||
$anlagenList[$node->id] = $prefix . $node->label;
|
||||
if (!empty($node->children)) {
|
||||
$flattenTree($node->children, $prefix . ' ');
|
||||
$isBuilding = in_array((int) $node->fk_anlage_type, $buildingTypeIds);
|
||||
|
||||
if ($isBuilding) {
|
||||
// Gebäude/Raum: nicht wählbar, aber Pfad als Kontext weitergeben
|
||||
$newPath = $path ? $path.' > '.$node->label : $node->label;
|
||||
if (!empty($node->children)) {
|
||||
$flattenTree($node->children, $newPath);
|
||||
}
|
||||
} else {
|
||||
// Gerät: in Liste aufnehmen mit Gebäude-Pfad als Kontext
|
||||
$typeInfo = !empty($node->type_short) ? $node->type_short : (!empty($node->type_label) ? $node->type_label : '');
|
||||
$label = '';
|
||||
if (!empty($path)) {
|
||||
$label = $path.' > ';
|
||||
}
|
||||
$label .= $node->label;
|
||||
if (!empty($typeInfo)) {
|
||||
$label .= ' ['.$typeInfo.']';
|
||||
}
|
||||
$anlagenList[$node->id] = array(
|
||||
'label' => $label,
|
||||
'picto' => !empty($node->type_picto) ? $node->type_picto : 'fa-cube',
|
||||
);
|
||||
// Rekursion in Geräte-Kinder
|
||||
if (!empty($node->children)) {
|
||||
$flattenTree($node->children, $path);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -154,8 +202,9 @@ print '<form method="POST" action="'.$_SERVER['PHP_SELF'].'">';
|
|||
print '<input type="hidden" name="token" value="'.newToken().'">';
|
||||
print '<input type="hidden" name="id" value="'.$id.'">';
|
||||
print '<input type="hidden" name="socid" value="'.$socId.'">';
|
||||
print '<input type="hidden" name="contactid" value="'.$contactId.'">';
|
||||
print '<input type="hidden" name="system_id" value="'.$systemId.'">';
|
||||
print '<input type="hidden" name="action" value="'.($id > 0 ? 'update' : 'create').'">';
|
||||
print '<input type="hidden" name="action" value="'.($id > 0 ? 'update' : 'add').'">';
|
||||
|
||||
print load_fiche_titre($title, '', 'object_kundenkarte@kundenkarte');
|
||||
|
||||
|
|
@ -163,21 +212,21 @@ print '<table class="border centpercent">';
|
|||
|
||||
// Source
|
||||
print '<tr><td class="titlefield fieldrequired">'.$langs->trans('Von (Quelle)').'</td>';
|
||||
print '<td><select name="fk_source" class="flat minwidth300">';
|
||||
print '<td><select name="fk_source" id="fk_source" class="flat minwidth300">';
|
||||
print '<option value="">-- Quelle wählen --</option>';
|
||||
foreach ($anlagenList as $aid => $alabel) {
|
||||
foreach ($anlagenList as $aid => $ainfo) {
|
||||
$selected = ($connection->fk_source == $aid || $sourceId == $aid) ? ' selected' : '';
|
||||
print '<option value="'.$aid.'"'.$selected.'>'.dol_escape_htmltag($alabel).'</option>';
|
||||
print '<option value="'.$aid.'" data-picto="'.dol_escape_htmltag($ainfo['picto']).'"'.$selected.'>'.dol_escape_htmltag($ainfo['label']).'</option>';
|
||||
}
|
||||
print '</select></td></tr>';
|
||||
|
||||
// Target
|
||||
print '<tr><td class="fieldrequired">'.$langs->trans('Nach (Ziel)').'</td>';
|
||||
print '<td><select name="fk_target" class="flat minwidth300">';
|
||||
print '<td><select name="fk_target" id="fk_target" class="flat minwidth300">';
|
||||
print '<option value="">-- Ziel wählen --</option>';
|
||||
foreach ($anlagenList as $aid => $alabel) {
|
||||
foreach ($anlagenList as $aid => $ainfo) {
|
||||
$selected = ($connection->fk_target == $aid) ? ' selected' : '';
|
||||
print '<option value="'.$aid.'"'.$selected.'>'.dol_escape_htmltag($alabel).'</option>';
|
||||
print '<option value="'.$aid.'" data-picto="'.dol_escape_htmltag($ainfo['picto']).'"'.$selected.'>'.dol_escape_htmltag($ainfo['label']).'</option>';
|
||||
}
|
||||
print '</select></td></tr>';
|
||||
|
||||
|
|
@ -223,14 +272,35 @@ print '</table>';
|
|||
|
||||
print '<div class="center" style="margin-top:20px;">';
|
||||
print '<button type="submit" class="button button-save">'.$langs->trans('Save').'</button>';
|
||||
print ' <a class="button button-cancel" href="'.dol_buildpath('/kundenkarte/tabs/anlagen.php', 1).'?id='.$socId.'&system='.$systemId.'">'.$langs->trans('Cancel').'</a>';
|
||||
print ' <a class="button button-cancel" href="'.$backUrl.'">'.$langs->trans('Cancel').'</a>';
|
||||
|
||||
if ($id > 0 && $user->hasRight('kundenkarte', 'write')) {
|
||||
print ' <a class="button button-delete" style="margin-left:20px;" href="'.$_SERVER['PHP_SELF'].'?id='.$id.'&socid='.$socId.'&system_id='.$systemId.'&action=delete&token='.newToken().'" onclick="return confirm(\'Verbindung wirklich löschen?\');">'.$langs->trans('Delete').'</a>';
|
||||
print ' <a class="button button-delete" style="margin-left:20px;" href="'.$_SERVER['PHP_SELF'].'?id='.$id.'&socid='.$socId.'&contactid='.$contactId.'&system_id='.$systemId.'&action=delete&token='.newToken().'" onclick="return confirm(\'Verbindung wirklich löschen?\');">'.$langs->trans('Delete').'</a>';
|
||||
}
|
||||
print '</div>';
|
||||
|
||||
print '</form>';
|
||||
|
||||
// Select2 mit Icons für Quelle/Ziel-Dropdowns
|
||||
print '<script>
|
||||
$(document).ready(function() {
|
||||
function formatAnlageOption(option) {
|
||||
if (!option.id) return option.text;
|
||||
var picto = $(option.element).data("picto");
|
||||
if (picto) {
|
||||
return $("<span><i class=\"fa " + picto + "\" style=\"width:20px;margin-right:8px;text-align:center;color:#666;\"></i>" + $("<span>").text(option.text).html() + "</span>");
|
||||
}
|
||||
return option.text;
|
||||
}
|
||||
$("#fk_source, #fk_target").select2({
|
||||
templateResult: formatAnlageOption,
|
||||
templateSelection: formatAnlageOption,
|
||||
width: "100%",
|
||||
placeholder: "-- Gerät wählen --",
|
||||
allowClear: true
|
||||
});
|
||||
});
|
||||
</script>';
|
||||
|
||||
llxFooter();
|
||||
$db->close();
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ class Anlage extends CommonObject
|
|||
public $fk_anlage_type;
|
||||
public $fk_parent;
|
||||
public $fk_system;
|
||||
public $fk_building_node;
|
||||
|
||||
public $manufacturer;
|
||||
public $model;
|
||||
|
|
@ -199,6 +200,7 @@ class Anlage extends CommonObject
|
|||
$this->fk_anlage_type = $obj->fk_anlage_type;
|
||||
$this->fk_parent = $obj->fk_parent;
|
||||
$this->fk_system = $obj->fk_system;
|
||||
$this->fk_building_node = isset($obj->fk_building_node) ? (int) $obj->fk_building_node : 0;
|
||||
|
||||
$this->manufacturer = $obj->manufacturer;
|
||||
$this->model = $obj->model;
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ class modKundenKarte extends DolibarrModules
|
|||
$this->editor_squarred_logo = ''; // Must be image filename into the module/img directory followed with @modulename. Example: 'myimage.png@kundenkarte'
|
||||
|
||||
// Possible values for version are: 'development', 'experimental', 'dolibarr', 'dolibarr_deprecated', 'experimental_deprecated' or a version string like 'x.y.z'
|
||||
$this->version = '4.0.3';
|
||||
$this->version = '5.0.0';
|
||||
// Url to the file with your last numberversion of this module
|
||||
//$this->url_last_version = 'http://www.example.com/versionmodule.txt';
|
||||
|
||||
|
|
@ -609,6 +609,12 @@ class modKundenKarte extends DolibarrModules
|
|||
|
||||
// v3.7.0: Add badge color for fields
|
||||
$this->migrate_v370_badge_color();
|
||||
|
||||
// v4.1.0: Add fk_building_node for room assignment
|
||||
$this->migrate_v410_building_node();
|
||||
|
||||
// v4.1.0: Graph-Positionen speichern
|
||||
$this->migrate_v410_graph_positions();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -703,6 +709,57 @@ class modKundenKarte extends DolibarrModules
|
|||
$this->db->query("ALTER TABLE ".$table." ADD COLUMN badge_color varchar(7) AFTER tree_display_mode");
|
||||
}
|
||||
|
||||
/**
|
||||
* Migration v4.1.0: Spalte fk_building_node für Raumzuordnung
|
||||
* Trennt Gebäude-/Raumzuordnung von technischem Parent (fk_parent)
|
||||
*/
|
||||
private function migrate_v410_building_node()
|
||||
{
|
||||
$table = MAIN_DB_PREFIX."kundenkarte_anlage";
|
||||
|
||||
// Prüfen ob Tabelle existiert
|
||||
$resql = $this->db->query("SHOW TABLES LIKE '".$this->db->escape($table)."'");
|
||||
if (!$resql || $this->db->num_rows($resql) == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prüfen ob Spalte bereits existiert
|
||||
$resql = $this->db->query("SHOW COLUMNS FROM ".$table." LIKE 'fk_building_node'");
|
||||
if ($resql && $this->db->num_rows($resql) > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Spalte hinzufügen
|
||||
$this->db->query("ALTER TABLE ".$table." ADD COLUMN fk_building_node integer DEFAULT 0 AFTER fk_system");
|
||||
|
||||
// Index für Performance
|
||||
$this->db->query("ALTER TABLE ".$table." ADD INDEX idx_anlage_building_node (fk_building_node)");
|
||||
}
|
||||
|
||||
/**
|
||||
* Migration v4.1.0: Graph-Positionen (x/y) in Anlage-Tabelle
|
||||
*/
|
||||
private function migrate_v410_graph_positions()
|
||||
{
|
||||
$table = MAIN_DB_PREFIX."kundenkarte_anlage";
|
||||
|
||||
// Prüfen ob Tabelle existiert
|
||||
$resql = $this->db->query("SHOW TABLES LIKE '".$this->db->escape($table)."'");
|
||||
if (!$resql || $this->db->num_rows($resql) == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prüfen ob Spalte bereits existiert
|
||||
$resql = $this->db->query("SHOW COLUMNS FROM ".$table." LIKE 'graph_x'");
|
||||
if ($resql && $this->db->num_rows($resql) > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Spalten hinzufügen
|
||||
$this->db->query("ALTER TABLE ".$table." ADD COLUMN graph_x double DEFAULT NULL AFTER fk_building_node");
|
||||
$this->db->query("ALTER TABLE ".$table." ADD COLUMN graph_y double DEFAULT NULL AFTER graph_x");
|
||||
}
|
||||
|
||||
/**
|
||||
* Function called when module is disabled.
|
||||
* Remove from database constants, boxes and permissions from Dolibarr database.
|
||||
|
|
|
|||
229
css/kundenkarte_cytoscape.css
Normal file
229
css/kundenkarte_cytoscape.css
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
/**
|
||||
* KundenKarte Graph-Ansicht Styles
|
||||
* Nutzt Dolibarr CSS-Variablen für Theme-Kompatibilität
|
||||
*/
|
||||
|
||||
/* Graph Container */
|
||||
.kundenkarte-graph-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin-top: 10px;
|
||||
overflow: visible;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#kundenkarte-graph-container {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 400px;
|
||||
min-height: 300px;
|
||||
max-height: 80vh;
|
||||
border: 1px solid var(--inputbordercolor, #3a3a3a);
|
||||
border-radius: 4px;
|
||||
background: var(--colorbackbody, #1d1e20);
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#kundenkarte-graph-container {
|
||||
height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Toolbar: Aktionen links, Zoom rechts */
|
||||
.kundenkarte-graph-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.kundenkarte-graph-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.kundenkarte-graph-actions .button {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.kundenkarte-graph-zoom-controls {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
#btn-graph-wheel-zoom.active {
|
||||
background: var(--butactionbg, #4390dc) !important;
|
||||
color: #fff !important;
|
||||
border-color: var(--butactionbg, #4390dc) !important;
|
||||
}
|
||||
|
||||
/* Legende */
|
||||
.kundenkarte-graph-legend {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
padding: 8px 12px;
|
||||
margin-top: 8px;
|
||||
background: var(--colorbacktitle1, rgba(30, 30, 50, 0.8));
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--colortext, #aaa);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.kundenkarte-graph-legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.kundenkarte-graph-legend-line {
|
||||
width: 20px;
|
||||
height: 2px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.kundenkarte-graph-legend-line.cable {
|
||||
background: #5a8a5a;
|
||||
}
|
||||
|
||||
.kundenkarte-graph-legend-line.passthrough {
|
||||
background: none;
|
||||
border-top: 2px dashed #505860;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.kundenkarte-graph-legend-box {
|
||||
width: 16px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.kundenkarte-graph-legend-box.building {
|
||||
background: #2a2b2d;
|
||||
border: 1px dashed #4390dc;
|
||||
}
|
||||
|
||||
.kundenkarte-graph-legend-box.device {
|
||||
background: #2d4a3a;
|
||||
border: 2px solid #5a9a6a;
|
||||
}
|
||||
|
||||
/* Loading Overlay */
|
||||
.kundenkarte-graph-loading {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: var(--colortext, #aaa);
|
||||
font-size: 14px;
|
||||
z-index: 10;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.kundenkarte-graph-loading i {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* Graph Tooltip */
|
||||
.kundenkarte-graph-tooltip {
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
background: var(--colorbacktabcard1, #1e2a3a);
|
||||
border: 1px solid var(--inputbordercolor, #3a6a8e);
|
||||
border-radius: 6px;
|
||||
padding: 10px 14px;
|
||||
font-size: 12px;
|
||||
color: var(--colortext, #ccc);
|
||||
max-width: 300px;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.kundenkarte-graph-tooltip .tooltip-title {
|
||||
font-weight: bold;
|
||||
color: var(--colortextlink, #7ab0d4);
|
||||
margin-bottom: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.kundenkarte-graph-tooltip .tooltip-type {
|
||||
color: var(--colortext, #888);
|
||||
font-size: 11px;
|
||||
margin-bottom: 6px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.kundenkarte-graph-tooltip .tooltip-field {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 2px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.kundenkarte-graph-tooltip .tooltip-field-label {
|
||||
color: var(--colortext, #888);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.kundenkarte-graph-tooltip .tooltip-field-value {
|
||||
color: var(--colortext, #ddd);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Leer-Zustand */
|
||||
.kundenkarte-graph-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 300px;
|
||||
color: var(--colortext, #666);
|
||||
font-size: 14px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.kundenkarte-graph-empty i {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* Mobile Anpassungen */
|
||||
@media (max-width: 768px) {
|
||||
.kundenkarte-graph-toolbar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.kundenkarte-graph-actions {
|
||||
flex: 1 1 100%;
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.kundenkarte-graph-actions .button {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 10px 8px !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
.kundenkarte-graph-zoom-controls {
|
||||
order: 1;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.kundenkarte-graph-zoom-controls .button {
|
||||
padding: 10px 8px !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
}
|
||||
3214
js/cose-base.js
Normal file
3214
js/cose-base.js
Normal file
File diff suppressed because it is too large
Load diff
458
js/cytoscape-cose-bilkent.js
Normal file
458
js/cytoscape-cose-bilkent.js
Normal file
|
|
@ -0,0 +1,458 @@
|
|||
(function webpackUniversalModuleDefinition(root, factory) {
|
||||
if(typeof exports === 'object' && typeof module === 'object')
|
||||
module.exports = factory(require("cose-base"));
|
||||
else if(typeof define === 'function' && define.amd)
|
||||
define(["cose-base"], factory);
|
||||
else if(typeof exports === 'object')
|
||||
exports["cytoscapeCoseBilkent"] = factory(require("cose-base"));
|
||||
else
|
||||
root["cytoscapeCoseBilkent"] = factory(root["coseBase"]);
|
||||
})(this, function(__WEBPACK_EXTERNAL_MODULE_0__) {
|
||||
return /******/ (function(modules) { // webpackBootstrap
|
||||
/******/ // The module cache
|
||||
/******/ var installedModules = {};
|
||||
/******/
|
||||
/******/ // The require function
|
||||
/******/ function __webpack_require__(moduleId) {
|
||||
/******/
|
||||
/******/ // Check if module is in cache
|
||||
/******/ if(installedModules[moduleId]) {
|
||||
/******/ return installedModules[moduleId].exports;
|
||||
/******/ }
|
||||
/******/ // Create a new module (and put it into the cache)
|
||||
/******/ var module = installedModules[moduleId] = {
|
||||
/******/ i: moduleId,
|
||||
/******/ l: false,
|
||||
/******/ exports: {}
|
||||
/******/ };
|
||||
/******/
|
||||
/******/ // Execute the module function
|
||||
/******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
|
||||
/******/
|
||||
/******/ // Flag the module as loaded
|
||||
/******/ module.l = true;
|
||||
/******/
|
||||
/******/ // Return the exports of the module
|
||||
/******/ return module.exports;
|
||||
/******/ }
|
||||
/******/
|
||||
/******/
|
||||
/******/ // expose the modules object (__webpack_modules__)
|
||||
/******/ __webpack_require__.m = modules;
|
||||
/******/
|
||||
/******/ // expose the module cache
|
||||
/******/ __webpack_require__.c = installedModules;
|
||||
/******/
|
||||
/******/ // identity function for calling harmony imports with the correct context
|
||||
/******/ __webpack_require__.i = function(value) { return value; };
|
||||
/******/
|
||||
/******/ // define getter function for harmony exports
|
||||
/******/ __webpack_require__.d = function(exports, name, getter) {
|
||||
/******/ if(!__webpack_require__.o(exports, name)) {
|
||||
/******/ Object.defineProperty(exports, name, {
|
||||
/******/ configurable: false,
|
||||
/******/ enumerable: true,
|
||||
/******/ get: getter
|
||||
/******/ });
|
||||
/******/ }
|
||||
/******/ };
|
||||
/******/
|
||||
/******/ // getDefaultExport function for compatibility with non-harmony modules
|
||||
/******/ __webpack_require__.n = function(module) {
|
||||
/******/ var getter = module && module.__esModule ?
|
||||
/******/ function getDefault() { return module['default']; } :
|
||||
/******/ function getModuleExports() { return module; };
|
||||
/******/ __webpack_require__.d(getter, 'a', getter);
|
||||
/******/ return getter;
|
||||
/******/ };
|
||||
/******/
|
||||
/******/ // Object.prototype.hasOwnProperty.call
|
||||
/******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };
|
||||
/******/
|
||||
/******/ // __webpack_public_path__
|
||||
/******/ __webpack_require__.p = "";
|
||||
/******/
|
||||
/******/ // Load entry module and return exports
|
||||
/******/ return __webpack_require__(__webpack_require__.s = 1);
|
||||
/******/ })
|
||||
/************************************************************************/
|
||||
/******/ ([
|
||||
/* 0 */
|
||||
/***/ (function(module, exports) {
|
||||
|
||||
module.exports = __WEBPACK_EXTERNAL_MODULE_0__;
|
||||
|
||||
/***/ }),
|
||||
/* 1 */
|
||||
/***/ (function(module, exports, __webpack_require__) {
|
||||
|
||||
"use strict";
|
||||
|
||||
|
||||
var LayoutConstants = __webpack_require__(0).layoutBase.LayoutConstants;
|
||||
var FDLayoutConstants = __webpack_require__(0).layoutBase.FDLayoutConstants;
|
||||
var CoSEConstants = __webpack_require__(0).CoSEConstants;
|
||||
var CoSELayout = __webpack_require__(0).CoSELayout;
|
||||
var CoSENode = __webpack_require__(0).CoSENode;
|
||||
var PointD = __webpack_require__(0).layoutBase.PointD;
|
||||
var DimensionD = __webpack_require__(0).layoutBase.DimensionD;
|
||||
|
||||
var defaults = {
|
||||
// Called on `layoutready`
|
||||
ready: function ready() {},
|
||||
// Called on `layoutstop`
|
||||
stop: function stop() {},
|
||||
// 'draft', 'default' or 'proof"
|
||||
// - 'draft' fast cooling rate
|
||||
// - 'default' moderate cooling rate
|
||||
// - "proof" slow cooling rate
|
||||
quality: 'default',
|
||||
// include labels in node dimensions
|
||||
nodeDimensionsIncludeLabels: false,
|
||||
// number of ticks per frame; higher is faster but more jerky
|
||||
refresh: 30,
|
||||
// Whether to fit the network view after when done
|
||||
fit: true,
|
||||
// Padding on fit
|
||||
padding: 10,
|
||||
// Whether to enable incremental mode
|
||||
randomize: true,
|
||||
// Node repulsion (non overlapping) multiplier
|
||||
nodeRepulsion: 4500,
|
||||
// Ideal edge (non nested) length
|
||||
idealEdgeLength: 50,
|
||||
// Divisor to compute edge forces
|
||||
edgeElasticity: 0.45,
|
||||
// Nesting factor (multiplier) to compute ideal edge length for nested edges
|
||||
nestingFactor: 0.1,
|
||||
// Gravity force (constant)
|
||||
gravity: 0.25,
|
||||
// Maximum number of iterations to perform
|
||||
numIter: 2500,
|
||||
// For enabling tiling
|
||||
tile: true,
|
||||
// Type of layout animation. The option set is {'during', 'end', false}
|
||||
animate: 'end',
|
||||
// Duration for animate:end
|
||||
animationDuration: 500,
|
||||
// Represents the amount of the vertical space to put between the zero degree members during the tiling operation(can also be a function)
|
||||
tilingPaddingVertical: 10,
|
||||
// Represents the amount of the horizontal space to put between the zero degree members during the tiling operation(can also be a function)
|
||||
tilingPaddingHorizontal: 10,
|
||||
// Gravity range (constant) for compounds
|
||||
gravityRangeCompound: 1.5,
|
||||
// Gravity force (constant) for compounds
|
||||
gravityCompound: 1.0,
|
||||
// Gravity range (constant)
|
||||
gravityRange: 3.8,
|
||||
// Initial cooling factor for incremental layout
|
||||
initialEnergyOnIncremental: 0.5
|
||||
};
|
||||
|
||||
function extend(defaults, options) {
|
||||
var obj = {};
|
||||
|
||||
for (var i in defaults) {
|
||||
obj[i] = defaults[i];
|
||||
}
|
||||
|
||||
for (var i in options) {
|
||||
obj[i] = options[i];
|
||||
}
|
||||
|
||||
return obj;
|
||||
};
|
||||
|
||||
function _CoSELayout(_options) {
|
||||
this.options = extend(defaults, _options);
|
||||
getUserOptions(this.options);
|
||||
}
|
||||
|
||||
var getUserOptions = function getUserOptions(options) {
|
||||
if (options.nodeRepulsion != null) CoSEConstants.DEFAULT_REPULSION_STRENGTH = FDLayoutConstants.DEFAULT_REPULSION_STRENGTH = options.nodeRepulsion;
|
||||
if (options.idealEdgeLength != null) CoSEConstants.DEFAULT_EDGE_LENGTH = FDLayoutConstants.DEFAULT_EDGE_LENGTH = options.idealEdgeLength;
|
||||
if (options.edgeElasticity != null) CoSEConstants.DEFAULT_SPRING_STRENGTH = FDLayoutConstants.DEFAULT_SPRING_STRENGTH = options.edgeElasticity;
|
||||
if (options.nestingFactor != null) CoSEConstants.PER_LEVEL_IDEAL_EDGE_LENGTH_FACTOR = FDLayoutConstants.PER_LEVEL_IDEAL_EDGE_LENGTH_FACTOR = options.nestingFactor;
|
||||
if (options.gravity != null) CoSEConstants.DEFAULT_GRAVITY_STRENGTH = FDLayoutConstants.DEFAULT_GRAVITY_STRENGTH = options.gravity;
|
||||
if (options.numIter != null) CoSEConstants.MAX_ITERATIONS = FDLayoutConstants.MAX_ITERATIONS = options.numIter;
|
||||
if (options.gravityRange != null) CoSEConstants.DEFAULT_GRAVITY_RANGE_FACTOR = FDLayoutConstants.DEFAULT_GRAVITY_RANGE_FACTOR = options.gravityRange;
|
||||
if (options.gravityCompound != null) CoSEConstants.DEFAULT_COMPOUND_GRAVITY_STRENGTH = FDLayoutConstants.DEFAULT_COMPOUND_GRAVITY_STRENGTH = options.gravityCompound;
|
||||
if (options.gravityRangeCompound != null) CoSEConstants.DEFAULT_COMPOUND_GRAVITY_RANGE_FACTOR = FDLayoutConstants.DEFAULT_COMPOUND_GRAVITY_RANGE_FACTOR = options.gravityRangeCompound;
|
||||
if (options.initialEnergyOnIncremental != null) CoSEConstants.DEFAULT_COOLING_FACTOR_INCREMENTAL = FDLayoutConstants.DEFAULT_COOLING_FACTOR_INCREMENTAL = options.initialEnergyOnIncremental;
|
||||
|
||||
if (options.quality == 'draft') LayoutConstants.QUALITY = 0;else if (options.quality == 'proof') LayoutConstants.QUALITY = 2;else LayoutConstants.QUALITY = 1;
|
||||
|
||||
CoSEConstants.NODE_DIMENSIONS_INCLUDE_LABELS = FDLayoutConstants.NODE_DIMENSIONS_INCLUDE_LABELS = LayoutConstants.NODE_DIMENSIONS_INCLUDE_LABELS = options.nodeDimensionsIncludeLabels;
|
||||
CoSEConstants.DEFAULT_INCREMENTAL = FDLayoutConstants.DEFAULT_INCREMENTAL = LayoutConstants.DEFAULT_INCREMENTAL = !options.randomize;
|
||||
CoSEConstants.ANIMATE = FDLayoutConstants.ANIMATE = LayoutConstants.ANIMATE = options.animate;
|
||||
CoSEConstants.TILE = options.tile;
|
||||
CoSEConstants.TILING_PADDING_VERTICAL = typeof options.tilingPaddingVertical === 'function' ? options.tilingPaddingVertical.call() : options.tilingPaddingVertical;
|
||||
CoSEConstants.TILING_PADDING_HORIZONTAL = typeof options.tilingPaddingHorizontal === 'function' ? options.tilingPaddingHorizontal.call() : options.tilingPaddingHorizontal;
|
||||
};
|
||||
|
||||
_CoSELayout.prototype.run = function () {
|
||||
var ready;
|
||||
var frameId;
|
||||
var options = this.options;
|
||||
var idToLNode = this.idToLNode = {};
|
||||
var layout = this.layout = new CoSELayout();
|
||||
var self = this;
|
||||
|
||||
self.stopped = false;
|
||||
|
||||
this.cy = this.options.cy;
|
||||
|
||||
this.cy.trigger({ type: 'layoutstart', layout: this });
|
||||
|
||||
var gm = layout.newGraphManager();
|
||||
this.gm = gm;
|
||||
|
||||
var nodes = this.options.eles.nodes();
|
||||
var edges = this.options.eles.edges();
|
||||
|
||||
this.root = gm.addRoot();
|
||||
this.processChildrenList(this.root, this.getTopMostNodes(nodes), layout);
|
||||
|
||||
for (var i = 0; i < edges.length; i++) {
|
||||
var edge = edges[i];
|
||||
var sourceNode = this.idToLNode[edge.data("source")];
|
||||
var targetNode = this.idToLNode[edge.data("target")];
|
||||
if (sourceNode !== targetNode && sourceNode.getEdgesBetween(targetNode).length == 0) {
|
||||
var e1 = gm.add(layout.newEdge(), sourceNode, targetNode);
|
||||
e1.id = edge.id();
|
||||
}
|
||||
}
|
||||
|
||||
var getPositions = function getPositions(ele, i) {
|
||||
if (typeof ele === "number") {
|
||||
ele = i;
|
||||
}
|
||||
var theId = ele.data('id');
|
||||
var lNode = self.idToLNode[theId];
|
||||
|
||||
return {
|
||||
x: lNode.getRect().getCenterX(),
|
||||
y: lNode.getRect().getCenterY()
|
||||
};
|
||||
};
|
||||
|
||||
/*
|
||||
* Reposition nodes in iterations animatedly
|
||||
*/
|
||||
var iterateAnimated = function iterateAnimated() {
|
||||
// Thigs to perform after nodes are repositioned on screen
|
||||
var afterReposition = function afterReposition() {
|
||||
if (options.fit) {
|
||||
options.cy.fit(options.eles, options.padding);
|
||||
}
|
||||
|
||||
if (!ready) {
|
||||
ready = true;
|
||||
self.cy.one('layoutready', options.ready);
|
||||
self.cy.trigger({ type: 'layoutready', layout: self });
|
||||
}
|
||||
};
|
||||
|
||||
var ticksPerFrame = self.options.refresh;
|
||||
var isDone;
|
||||
|
||||
for (var i = 0; i < ticksPerFrame && !isDone; i++) {
|
||||
isDone = self.stopped || self.layout.tick();
|
||||
}
|
||||
|
||||
// If layout is done
|
||||
if (isDone) {
|
||||
// If the layout is not a sublayout and it is successful perform post layout.
|
||||
if (layout.checkLayoutSuccess() && !layout.isSubLayout) {
|
||||
layout.doPostLayout();
|
||||
}
|
||||
|
||||
// If layout has a tilingPostLayout function property call it.
|
||||
if (layout.tilingPostLayout) {
|
||||
layout.tilingPostLayout();
|
||||
}
|
||||
|
||||
layout.isLayoutFinished = true;
|
||||
|
||||
self.options.eles.nodes().positions(getPositions);
|
||||
|
||||
afterReposition();
|
||||
|
||||
// trigger layoutstop when the layout stops (e.g. finishes)
|
||||
self.cy.one('layoutstop', self.options.stop);
|
||||
self.cy.trigger({ type: 'layoutstop', layout: self });
|
||||
|
||||
if (frameId) {
|
||||
cancelAnimationFrame(frameId);
|
||||
}
|
||||
|
||||
ready = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var animationData = self.layout.getPositionsData(); // Get positions of layout nodes note that all nodes may not be layout nodes because of tiling
|
||||
|
||||
// Position nodes, for the nodes whose id does not included in data (because they are removed from their parents and included in dummy compounds)
|
||||
// use position of their ancestors or dummy ancestors
|
||||
options.eles.nodes().positions(function (ele, i) {
|
||||
if (typeof ele === "number") {
|
||||
ele = i;
|
||||
}
|
||||
// If ele is a compound node, then its position will be defined by its children
|
||||
if (!ele.isParent()) {
|
||||
var theId = ele.id();
|
||||
var pNode = animationData[theId];
|
||||
var temp = ele;
|
||||
// If pNode is undefined search until finding position data of its first ancestor (It may be dummy as well)
|
||||
while (pNode == null) {
|
||||
pNode = animationData[temp.data('parent')] || animationData['DummyCompound_' + temp.data('parent')];
|
||||
animationData[theId] = pNode;
|
||||
temp = temp.parent()[0];
|
||||
if (temp == undefined) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (pNode != null) {
|
||||
return {
|
||||
x: pNode.x,
|
||||
y: pNode.y
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
x: ele.position('x'),
|
||||
y: ele.position('y')
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
afterReposition();
|
||||
|
||||
frameId = requestAnimationFrame(iterateAnimated);
|
||||
};
|
||||
|
||||
/*
|
||||
* Listen 'layoutstarted' event and start animated iteration if animate option is 'during'
|
||||
*/
|
||||
layout.addListener('layoutstarted', function () {
|
||||
if (self.options.animate === 'during') {
|
||||
frameId = requestAnimationFrame(iterateAnimated);
|
||||
}
|
||||
});
|
||||
|
||||
layout.runLayout(); // Run cose layout
|
||||
|
||||
/*
|
||||
* If animate option is not 'during' ('end' or false) perform these here (If it is 'during' similar things are already performed)
|
||||
*/
|
||||
if (this.options.animate !== "during") {
|
||||
self.options.eles.nodes().not(":parent").layoutPositions(self, self.options, getPositions); // Use layout positions to reposition the nodes it considers the options parameter
|
||||
ready = false;
|
||||
}
|
||||
|
||||
return this; // chaining
|
||||
};
|
||||
|
||||
//Get the top most ones of a list of nodes
|
||||
_CoSELayout.prototype.getTopMostNodes = function (nodes) {
|
||||
var nodesMap = {};
|
||||
for (var i = 0; i < nodes.length; i++) {
|
||||
nodesMap[nodes[i].id()] = true;
|
||||
}
|
||||
var roots = nodes.filter(function (ele, i) {
|
||||
if (typeof ele === "number") {
|
||||
ele = i;
|
||||
}
|
||||
var parent = ele.parent()[0];
|
||||
while (parent != null) {
|
||||
if (nodesMap[parent.id()]) {
|
||||
return false;
|
||||
}
|
||||
parent = parent.parent()[0];
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return roots;
|
||||
};
|
||||
|
||||
_CoSELayout.prototype.processChildrenList = function (parent, children, layout) {
|
||||
var size = children.length;
|
||||
for (var i = 0; i < size; i++) {
|
||||
var theChild = children[i];
|
||||
var children_of_children = theChild.children();
|
||||
var theNode;
|
||||
|
||||
var dimensions = theChild.layoutDimensions({
|
||||
nodeDimensionsIncludeLabels: this.options.nodeDimensionsIncludeLabels
|
||||
});
|
||||
|
||||
if (theChild.outerWidth() != null && theChild.outerHeight() != null) {
|
||||
theNode = parent.add(new CoSENode(layout.graphManager, new PointD(theChild.position('x') - dimensions.w / 2, theChild.position('y') - dimensions.h / 2), new DimensionD(parseFloat(dimensions.w), parseFloat(dimensions.h))));
|
||||
} else {
|
||||
theNode = parent.add(new CoSENode(this.graphManager));
|
||||
}
|
||||
// Attach id to the layout node
|
||||
theNode.id = theChild.data("id");
|
||||
// Attach the paddings of cy node to layout node
|
||||
theNode.paddingLeft = parseInt(theChild.css('padding'));
|
||||
theNode.paddingTop = parseInt(theChild.css('padding'));
|
||||
theNode.paddingRight = parseInt(theChild.css('padding'));
|
||||
theNode.paddingBottom = parseInt(theChild.css('padding'));
|
||||
|
||||
//Attach the label properties to compound if labels will be included in node dimensions
|
||||
if (this.options.nodeDimensionsIncludeLabels) {
|
||||
if (theChild.isParent()) {
|
||||
var labelWidth = theChild.boundingBox({ includeLabels: true, includeNodes: false }).w;
|
||||
var labelHeight = theChild.boundingBox({ includeLabels: true, includeNodes: false }).h;
|
||||
var labelPos = theChild.css("text-halign");
|
||||
theNode.labelWidth = labelWidth;
|
||||
theNode.labelHeight = labelHeight;
|
||||
theNode.labelPos = labelPos;
|
||||
}
|
||||
}
|
||||
|
||||
// Map the layout node
|
||||
this.idToLNode[theChild.data("id")] = theNode;
|
||||
|
||||
if (isNaN(theNode.rect.x)) {
|
||||
theNode.rect.x = 0;
|
||||
}
|
||||
|
||||
if (isNaN(theNode.rect.y)) {
|
||||
theNode.rect.y = 0;
|
||||
}
|
||||
|
||||
if (children_of_children != null && children_of_children.length > 0) {
|
||||
var theNewGraph;
|
||||
theNewGraph = layout.getGraphManager().add(layout.newGraph(), theNode);
|
||||
this.processChildrenList(theNewGraph, children_of_children, layout);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief : called on continuous layouts to stop them before they finish
|
||||
*/
|
||||
_CoSELayout.prototype.stop = function () {
|
||||
this.stopped = true;
|
||||
|
||||
return this; // chaining
|
||||
};
|
||||
|
||||
var register = function register(cytoscape) {
|
||||
// var Layout = getLayout( cytoscape );
|
||||
|
||||
cytoscape('layout', 'cose-bilkent', _CoSELayout);
|
||||
};
|
||||
|
||||
// auto reg for globals
|
||||
if (typeof cytoscape !== 'undefined') {
|
||||
register(cytoscape);
|
||||
}
|
||||
|
||||
module.exports = register;
|
||||
|
||||
/***/ })
|
||||
/******/ ]);
|
||||
});
|
||||
397
js/cytoscape-dagre.js
Normal file
397
js/cytoscape-dagre.js
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
(function webpackUniversalModuleDefinition(root, factory) {
|
||||
if(typeof exports === 'object' && typeof module === 'object')
|
||||
module.exports = factory(require("dagre"));
|
||||
else if(typeof define === 'function' && define.amd)
|
||||
define(["dagre"], factory);
|
||||
else if(typeof exports === 'object')
|
||||
exports["cytoscapeDagre"] = factory(require("dagre"));
|
||||
else
|
||||
root["cytoscapeDagre"] = factory(root["dagre"]);
|
||||
})(this, function(__WEBPACK_EXTERNAL_MODULE__4__) {
|
||||
return /******/ (function(modules) { // webpackBootstrap
|
||||
/******/ // The module cache
|
||||
/******/ var installedModules = {};
|
||||
/******/
|
||||
/******/ // The require function
|
||||
/******/ function __webpack_require__(moduleId) {
|
||||
/******/
|
||||
/******/ // Check if module is in cache
|
||||
/******/ if(installedModules[moduleId]) {
|
||||
/******/ return installedModules[moduleId].exports;
|
||||
/******/ }
|
||||
/******/ // Create a new module (and put it into the cache)
|
||||
/******/ var module = installedModules[moduleId] = {
|
||||
/******/ i: moduleId,
|
||||
/******/ l: false,
|
||||
/******/ exports: {}
|
||||
/******/ };
|
||||
/******/
|
||||
/******/ // Execute the module function
|
||||
/******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
|
||||
/******/
|
||||
/******/ // Flag the module as loaded
|
||||
/******/ module.l = true;
|
||||
/******/
|
||||
/******/ // Return the exports of the module
|
||||
/******/ return module.exports;
|
||||
/******/ }
|
||||
/******/
|
||||
/******/
|
||||
/******/ // expose the modules object (__webpack_modules__)
|
||||
/******/ __webpack_require__.m = modules;
|
||||
/******/
|
||||
/******/ // expose the module cache
|
||||
/******/ __webpack_require__.c = installedModules;
|
||||
/******/
|
||||
/******/ // define getter function for harmony exports
|
||||
/******/ __webpack_require__.d = function(exports, name, getter) {
|
||||
/******/ if(!__webpack_require__.o(exports, name)) {
|
||||
/******/ Object.defineProperty(exports, name, { enumerable: true, get: getter });
|
||||
/******/ }
|
||||
/******/ };
|
||||
/******/
|
||||
/******/ // define __esModule on exports
|
||||
/******/ __webpack_require__.r = function(exports) {
|
||||
/******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
|
||||
/******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
||||
/******/ }
|
||||
/******/ Object.defineProperty(exports, '__esModule', { value: true });
|
||||
/******/ };
|
||||
/******/
|
||||
/******/ // create a fake namespace object
|
||||
/******/ // mode & 1: value is a module id, require it
|
||||
/******/ // mode & 2: merge all properties of value into the ns
|
||||
/******/ // mode & 4: return value when already ns object
|
||||
/******/ // mode & 8|1: behave like require
|
||||
/******/ __webpack_require__.t = function(value, mode) {
|
||||
/******/ if(mode & 1) value = __webpack_require__(value);
|
||||
/******/ if(mode & 8) return value;
|
||||
/******/ if((mode & 4) && typeof value === 'object' && value && value.__esModule) return value;
|
||||
/******/ var ns = Object.create(null);
|
||||
/******/ __webpack_require__.r(ns);
|
||||
/******/ Object.defineProperty(ns, 'default', { enumerable: true, value: value });
|
||||
/******/ if(mode & 2 && typeof value != 'string') for(var key in value) __webpack_require__.d(ns, key, function(key) { return value[key]; }.bind(null, key));
|
||||
/******/ return ns;
|
||||
/******/ };
|
||||
/******/
|
||||
/******/ // getDefaultExport function for compatibility with non-harmony modules
|
||||
/******/ __webpack_require__.n = function(module) {
|
||||
/******/ var getter = module && module.__esModule ?
|
||||
/******/ function getDefault() { return module['default']; } :
|
||||
/******/ function getModuleExports() { return module; };
|
||||
/******/ __webpack_require__.d(getter, 'a', getter);
|
||||
/******/ return getter;
|
||||
/******/ };
|
||||
/******/
|
||||
/******/ // Object.prototype.hasOwnProperty.call
|
||||
/******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };
|
||||
/******/
|
||||
/******/ // __webpack_public_path__
|
||||
/******/ __webpack_require__.p = "";
|
||||
/******/
|
||||
/******/
|
||||
/******/ // Load entry module and return exports
|
||||
/******/ return __webpack_require__(__webpack_require__.s = 0);
|
||||
/******/ })
|
||||
/************************************************************************/
|
||||
/******/ ([
|
||||
/* 0 */
|
||||
/***/ (function(module, exports, __webpack_require__) {
|
||||
|
||||
var impl = __webpack_require__(1); // registers the extension on a cytoscape lib ref
|
||||
|
||||
|
||||
var register = function register(cytoscape) {
|
||||
if (!cytoscape) {
|
||||
return;
|
||||
} // can't register if cytoscape unspecified
|
||||
|
||||
|
||||
cytoscape('layout', 'dagre', impl); // register with cytoscape.js
|
||||
};
|
||||
|
||||
if (typeof cytoscape !== 'undefined') {
|
||||
// expose to global cytoscape (i.e. window.cytoscape)
|
||||
register(cytoscape);
|
||||
}
|
||||
|
||||
module.exports = register;
|
||||
|
||||
/***/ }),
|
||||
/* 1 */
|
||||
/***/ (function(module, exports, __webpack_require__) {
|
||||
|
||||
function _typeof(obj) { "@babel/helpers - typeof"; if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") { _typeof = function _typeof(obj) { return typeof obj; }; } else { _typeof = function _typeof(obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; } return _typeof(obj); }
|
||||
|
||||
var isFunction = function isFunction(o) {
|
||||
return typeof o === 'function';
|
||||
};
|
||||
|
||||
var defaults = __webpack_require__(2);
|
||||
|
||||
var assign = __webpack_require__(3);
|
||||
|
||||
var dagre = __webpack_require__(4); // constructor
|
||||
// options : object containing layout options
|
||||
|
||||
|
||||
function DagreLayout(options) {
|
||||
this.options = assign({}, defaults, options);
|
||||
} // runs the layout
|
||||
|
||||
|
||||
DagreLayout.prototype.run = function () {
|
||||
var options = this.options;
|
||||
var layout = this;
|
||||
var cy = options.cy; // cy is automatically populated for us in the constructor
|
||||
|
||||
var eles = options.eles;
|
||||
|
||||
var getVal = function getVal(ele, val) {
|
||||
return isFunction(val) ? val.apply(ele, [ele]) : val;
|
||||
};
|
||||
|
||||
var bb = options.boundingBox || {
|
||||
x1: 0,
|
||||
y1: 0,
|
||||
w: cy.width(),
|
||||
h: cy.height()
|
||||
};
|
||||
|
||||
if (bb.x2 === undefined) {
|
||||
bb.x2 = bb.x1 + bb.w;
|
||||
}
|
||||
|
||||
if (bb.w === undefined) {
|
||||
bb.w = bb.x2 - bb.x1;
|
||||
}
|
||||
|
||||
if (bb.y2 === undefined) {
|
||||
bb.y2 = bb.y1 + bb.h;
|
||||
}
|
||||
|
||||
if (bb.h === undefined) {
|
||||
bb.h = bb.y2 - bb.y1;
|
||||
}
|
||||
|
||||
var g = new dagre.graphlib.Graph({
|
||||
multigraph: true,
|
||||
compound: true
|
||||
});
|
||||
var gObj = {};
|
||||
|
||||
var setGObj = function setGObj(name, val) {
|
||||
if (val != null) {
|
||||
gObj[name] = val;
|
||||
}
|
||||
};
|
||||
|
||||
setGObj('nodesep', options.nodeSep);
|
||||
setGObj('edgesep', options.edgeSep);
|
||||
setGObj('ranksep', options.rankSep);
|
||||
setGObj('rankdir', options.rankDir);
|
||||
setGObj('align', options.align);
|
||||
setGObj('ranker', options.ranker);
|
||||
setGObj('acyclicer', options.acyclicer);
|
||||
g.setGraph(gObj);
|
||||
g.setDefaultEdgeLabel(function () {
|
||||
return {};
|
||||
});
|
||||
g.setDefaultNodeLabel(function () {
|
||||
return {};
|
||||
}); // add nodes to dagre
|
||||
|
||||
var nodes = eles.nodes();
|
||||
|
||||
if (isFunction(options.sort)) {
|
||||
nodes = nodes.sort(options.sort);
|
||||
}
|
||||
|
||||
for (var i = 0; i < nodes.length; i++) {
|
||||
var node = nodes[i];
|
||||
var nbb = node.layoutDimensions(options);
|
||||
g.setNode(node.id(), {
|
||||
width: nbb.w,
|
||||
height: nbb.h,
|
||||
name: node.id()
|
||||
}); // console.log( g.node(node.id()) );
|
||||
} // set compound parents
|
||||
|
||||
|
||||
for (var _i = 0; _i < nodes.length; _i++) {
|
||||
var _node = nodes[_i];
|
||||
|
||||
if (_node.isChild()) {
|
||||
g.setParent(_node.id(), _node.parent().id());
|
||||
}
|
||||
} // add edges to dagre
|
||||
|
||||
|
||||
var edges = eles.edges().stdFilter(function (edge) {
|
||||
return !edge.source().isParent() && !edge.target().isParent(); // dagre can't handle edges on compound nodes
|
||||
});
|
||||
|
||||
if (isFunction(options.sort)) {
|
||||
edges = edges.sort(options.sort);
|
||||
}
|
||||
|
||||
for (var _i2 = 0; _i2 < edges.length; _i2++) {
|
||||
var edge = edges[_i2];
|
||||
g.setEdge(edge.source().id(), edge.target().id(), {
|
||||
minlen: getVal(edge, options.minLen),
|
||||
weight: getVal(edge, options.edgeWeight),
|
||||
name: edge.id()
|
||||
}, edge.id()); // console.log( g.edge(edge.source().id(), edge.target().id(), edge.id()) );
|
||||
}
|
||||
|
||||
dagre.layout(g);
|
||||
var gNodeIds = g.nodes();
|
||||
|
||||
for (var _i3 = 0; _i3 < gNodeIds.length; _i3++) {
|
||||
var id = gNodeIds[_i3];
|
||||
var n = g.node(id);
|
||||
cy.getElementById(id).scratch().dagre = n;
|
||||
}
|
||||
|
||||
var dagreBB;
|
||||
|
||||
if (options.boundingBox) {
|
||||
dagreBB = {
|
||||
x1: Infinity,
|
||||
x2: -Infinity,
|
||||
y1: Infinity,
|
||||
y2: -Infinity
|
||||
};
|
||||
nodes.forEach(function (node) {
|
||||
var dModel = node.scratch().dagre;
|
||||
dagreBB.x1 = Math.min(dagreBB.x1, dModel.x);
|
||||
dagreBB.x2 = Math.max(dagreBB.x2, dModel.x);
|
||||
dagreBB.y1 = Math.min(dagreBB.y1, dModel.y);
|
||||
dagreBB.y2 = Math.max(dagreBB.y2, dModel.y);
|
||||
});
|
||||
dagreBB.w = dagreBB.x2 - dagreBB.x1;
|
||||
dagreBB.h = dagreBB.y2 - dagreBB.y1;
|
||||
} else {
|
||||
dagreBB = bb;
|
||||
}
|
||||
|
||||
var constrainPos = function constrainPos(p) {
|
||||
if (options.boundingBox) {
|
||||
var xPct = dagreBB.w === 0 ? 0 : (p.x - dagreBB.x1) / dagreBB.w;
|
||||
var yPct = dagreBB.h === 0 ? 0 : (p.y - dagreBB.y1) / dagreBB.h;
|
||||
return {
|
||||
x: bb.x1 + xPct * bb.w,
|
||||
y: bb.y1 + yPct * bb.h
|
||||
};
|
||||
} else {
|
||||
return p;
|
||||
}
|
||||
};
|
||||
|
||||
nodes.layoutPositions(layout, options, function (ele) {
|
||||
ele = _typeof(ele) === "object" ? ele : this;
|
||||
var dModel = ele.scratch().dagre;
|
||||
return constrainPos({
|
||||
x: dModel.x,
|
||||
y: dModel.y
|
||||
});
|
||||
});
|
||||
return this; // chaining
|
||||
};
|
||||
|
||||
module.exports = DagreLayout;
|
||||
|
||||
/***/ }),
|
||||
/* 2 */
|
||||
/***/ (function(module, exports) {
|
||||
|
||||
var defaults = {
|
||||
// dagre algo options, uses default value on undefined
|
||||
nodeSep: undefined,
|
||||
// the separation between adjacent nodes in the same rank
|
||||
edgeSep: undefined,
|
||||
// the separation between adjacent edges in the same rank
|
||||
rankSep: undefined,
|
||||
// the separation between adjacent nodes in the same rank
|
||||
rankDir: undefined,
|
||||
// 'TB' for top to bottom flow, 'LR' for left to right,
|
||||
align: undefined,
|
||||
// alignment for rank nodes. Can be 'UL', 'UR', 'DL', or 'DR', where U = up, D = down, L = left, and R = right
|
||||
acyclicer: undefined,
|
||||
// If set to 'greedy', uses a greedy heuristic for finding a feedback arc set for a graph.
|
||||
// A feedback arc set is a set of edges that can be removed to make a graph acyclic.
|
||||
ranker: undefined,
|
||||
// Type of algorithm to assigns a rank to each node in the input graph.
|
||||
// Possible values: network-simplex, tight-tree or longest-path
|
||||
minLen: function minLen(edge) {
|
||||
return 1;
|
||||
},
|
||||
// number of ranks to keep between the source and target of the edge
|
||||
edgeWeight: function edgeWeight(edge) {
|
||||
return 1;
|
||||
},
|
||||
// higher weight edges are generally made shorter and straighter than lower weight edges
|
||||
// general layout options
|
||||
fit: true,
|
||||
// whether to fit to viewport
|
||||
padding: 30,
|
||||
// fit padding
|
||||
spacingFactor: undefined,
|
||||
// Applies a multiplicative factor (>0) to expand or compress the overall area that the nodes take up
|
||||
nodeDimensionsIncludeLabels: false,
|
||||
// whether labels should be included in determining the space used by a node
|
||||
animate: false,
|
||||
// whether to transition the node positions
|
||||
animateFilter: function animateFilter(node, i) {
|
||||
return true;
|
||||
},
|
||||
// whether to animate specific nodes when animation is on; non-animated nodes immediately go to their final positions
|
||||
animationDuration: 500,
|
||||
// duration of animation in ms if enabled
|
||||
animationEasing: undefined,
|
||||
// easing of animation if enabled
|
||||
boundingBox: undefined,
|
||||
// constrain layout bounds; { x1, y1, x2, y2 } or { x1, y1, w, h }
|
||||
transform: function transform(node, pos) {
|
||||
return pos;
|
||||
},
|
||||
// a function that applies a transform to the final node position
|
||||
ready: function ready() {},
|
||||
// on layoutready
|
||||
sort: undefined,
|
||||
// a sorting function to order the nodes and edges; e.g. function(a, b){ return a.data('weight') - b.data('weight') }
|
||||
// because cytoscape dagre creates a directed graph, and directed graphs use the node order as a tie breaker when
|
||||
// defining the topology of a graph, this sort function can help ensure the correct order of the nodes/edges.
|
||||
// this feature is most useful when adding and removing the same nodes and edges multiple times in a graph.
|
||||
stop: function stop() {} // on layoutstop
|
||||
|
||||
};
|
||||
module.exports = defaults;
|
||||
|
||||
/***/ }),
|
||||
/* 3 */
|
||||
/***/ (function(module, exports) {
|
||||
|
||||
// Simple, internal Object.assign() polyfill for options objects etc.
|
||||
module.exports = Object.assign != null ? Object.assign.bind(Object) : function (tgt) {
|
||||
for (var _len = arguments.length, srcs = new Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) {
|
||||
srcs[_key - 1] = arguments[_key];
|
||||
}
|
||||
|
||||
srcs.forEach(function (src) {
|
||||
Object.keys(src).forEach(function (k) {
|
||||
return tgt[k] = src[k];
|
||||
});
|
||||
});
|
||||
return tgt;
|
||||
};
|
||||
|
||||
/***/ }),
|
||||
/* 4 */
|
||||
/***/ (function(module, exports) {
|
||||
|
||||
module.exports = __WEBPACK_EXTERNAL_MODULE__4__;
|
||||
|
||||
/***/ })
|
||||
/******/ ]);
|
||||
});
|
||||
32
js/cytoscape.min.js
vendored
Normal file
32
js/cytoscape.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
3809
js/dagre.min.js
vendored
Normal file
3809
js/dagre.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
850
js/kundenkarte_cytoscape.js
Normal file
850
js/kundenkarte_cytoscape.js
Normal file
|
|
@ -0,0 +1,850 @@
|
|||
/**
|
||||
* 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
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
})();
|
||||
5230
js/layout-base.js
Normal file
5230
js/layout-base.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -322,6 +322,19 @@ ErrorNoFileSelected = Keine Datei ausgewaehlt
|
|||
PDFTemplateHelp = Laden Sie eine PDF-Datei als Hintergrund/Briefpapier fuer den Export hoch. Die erste Seite wird als Vorlage verwendet.
|
||||
ExportTreeAsPDF = Als PDF exportieren
|
||||
|
||||
# View Mode
|
||||
DefaultViewMode = Standard-Ansichtsmodus fuer Anlagen
|
||||
ViewModeTree = Baumansicht (klassisch)
|
||||
ViewModeGraph = Graph-Ansicht (Cytoscape)
|
||||
SpatialView = Raeumlich
|
||||
TechnicalView = Technisch
|
||||
GraphLoading = Graph wird geladen...
|
||||
GraphLegendRoom = Raum/Gebaeude
|
||||
GraphLegendDevice = Geraet
|
||||
GraphLegendCable = Kabel
|
||||
GraphLegendPassthrough = Durchgeschleift
|
||||
GraphLegendHierarchy = Hierarchie
|
||||
|
||||
# Tree Display Settings
|
||||
TreeDisplaySettings = Baum-Anzeige Einstellungen
|
||||
TreeInfoDisplayMode = Zusatzinfos-Anzeige
|
||||
|
|
|
|||
|
|
@ -187,6 +187,19 @@ ErrorNoFileSelected = No file selected
|
|||
PDFTemplateHelp = Upload a PDF file as background/letterhead for export. The first page will be used as template.
|
||||
ExportTreeAsPDF = Export as PDF
|
||||
|
||||
# View Mode
|
||||
DefaultViewMode = Default view mode for installations
|
||||
ViewModeTree = Tree view (classic)
|
||||
ViewModeGraph = Graph view (Cytoscape)
|
||||
SpatialView = Spatial
|
||||
TechnicalView = Technical
|
||||
GraphLoading = Loading graph...
|
||||
GraphLegendRoom = Room/Building
|
||||
GraphLegendDevice = Device
|
||||
GraphLegendCable = Cable
|
||||
GraphLegendPassthrough = Passthrough
|
||||
GraphLegendHierarchy = Hierarchy
|
||||
|
||||
# Tree Display Settings
|
||||
TreeDisplaySettings = Tree Display Settings
|
||||
TreeInfoDisplayMode = Info Display Mode
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ CREATE TABLE llx_kundenkarte_anlage
|
|||
fk_parent integer DEFAULT 0 NOT NULL,
|
||||
|
||||
fk_system integer NOT NULL,
|
||||
fk_building_node integer DEFAULT 0,
|
||||
|
||||
manufacturer varchar(128),
|
||||
model varchar(128),
|
||||
|
|
|
|||
163
tabs/anlagen.php
163
tabs/anlagen.php
|
|
@ -365,7 +365,24 @@ if ($action == 'togglepin' && $permissiontoadd) {
|
|||
// Use Dolibarr standard button classes
|
||||
|
||||
$title = $langs->trans('TechnicalInstallations').' - '.$object->name;
|
||||
llxHeader('', $title, '', '', 0, 0, array('/kundenkarte/js/pathfinding.min.js', '/kundenkarte/js/kundenkarte.js?v='.time()), array('/kundenkarte/css/kundenkarte.css?v='.time()));
|
||||
|
||||
// Ansichtsmodus (Admin-Setting)
|
||||
$viewMode = getDolGlobalString('KUNDENKARTE_DEFAULT_VIEW', 'tree');
|
||||
|
||||
$jsFiles = array('/kundenkarte/js/kundenkarte.js?v='.time());
|
||||
$cssFiles = array('/kundenkarte/css/kundenkarte.css?v='.time());
|
||||
|
||||
if ($viewMode === 'graph') {
|
||||
$jsFiles[] = '/kundenkarte/js/dagre.min.js';
|
||||
$jsFiles[] = '/kundenkarte/js/cytoscape.min.js';
|
||||
$jsFiles[] = '/kundenkarte/js/cytoscape-dagre.js';
|
||||
$jsFiles[] = '/kundenkarte/js/kundenkarte_cytoscape.js?v='.time();
|
||||
$cssFiles[] = '/kundenkarte/css/kundenkarte_cytoscape.css?v='.time();
|
||||
} else {
|
||||
array_unshift($jsFiles, '/kundenkarte/js/pathfinding.min.js');
|
||||
}
|
||||
|
||||
llxHeader('', $title, '', '', 0, 0, $jsFiles, $cssFiles);
|
||||
|
||||
// Prepare tabs
|
||||
$head = societe_prepare_head($object);
|
||||
|
|
@ -452,24 +469,43 @@ print '</div>';
|
|||
// Expand/Collapse buttons (only in tree view, not in create/edit/view/copy)
|
||||
$isTreeView = !in_array($action, array('create', 'edit', 'view', 'copy'));
|
||||
if ($isTreeView) {
|
||||
print '<div class="kundenkarte-tree-controls">';
|
||||
// Compact mode toggle (visible on mobile)
|
||||
print '<button type="button" class="kundenkarte-view-toggle" id="btn-compact-mode" title="Kompakte Ansicht">';
|
||||
print '<i class="fa fa-compress"></i> <span>Kompakt</span>';
|
||||
print '</button>';
|
||||
print '<button type="button" class="button small" id="btn-expand-all" title="'.$langs->trans('ExpandAll').'">';
|
||||
print '<i class="fa fa-expand"></i> '.$langs->trans('ExpandAll');
|
||||
print '</button>';
|
||||
print '<button type="button" class="button small" id="btn-collapse-all" title="'.$langs->trans('CollapseAll').'">';
|
||||
print '<i class="fa fa-compress"></i> '.$langs->trans('CollapseAll');
|
||||
print '</button>';
|
||||
if ($systemId > 0) {
|
||||
$exportUrl = dol_buildpath('/kundenkarte/ajax/export_tree_pdf.php', 1).'?socid='.$id.'&system='.$systemId;
|
||||
print '<a class="button small" href="'.$exportUrl.'" title="'.$langs->trans('ExportPDF').'" target="_blank">';
|
||||
print '<i class="fa fa-file-pdf-o"></i> PDF Export';
|
||||
print '</a>';
|
||||
if ($viewMode === 'graph') {
|
||||
// Graph Controls: Aktionen + Zoom
|
||||
print '<div class="kundenkarte-graph-toolbar">';
|
||||
if ($user->hasRight('kundenkarte', 'write')) {
|
||||
print '<div class="kundenkarte-graph-actions">';
|
||||
print '<a class="button small" href="'.$_SERVER['PHP_SELF'].'?id='.$id.'&action=create&system='.$systemId.'"><i class="fa fa-plus"></i> '.$langs->trans('AddElement').'</a>';
|
||||
print '<a class="button small" href="'.dol_buildpath('/kundenkarte/anlage_connection.php', 1).'?socid='.$id.'&system_id='.$systemId.'&action=create"><i class="fa fa-plug"></i> '.$langs->trans('AddConnection').'</a>';
|
||||
print '</div>';
|
||||
}
|
||||
print '<div class="kundenkarte-graph-zoom-controls">';
|
||||
print '<button type="button" class="button small" id="btn-graph-reset-layout" title="Layout zurücksetzen"><i class="fa fa-undo"></i></button>';
|
||||
print '<button type="button" class="button small" id="btn-graph-wheel-zoom" title="Mausrad-Zoom ein"><i class="fa fa-mouse-pointer"></i></button>';
|
||||
print '<button type="button" class="button small" id="btn-graph-zoom-in" title="Zoom +"><i class="fa fa-search-plus"></i></button>';
|
||||
print '<button type="button" class="button small" id="btn-graph-zoom-out" title="Zoom -"><i class="fa fa-search-minus"></i></button>';
|
||||
print '<button type="button" class="button small" id="btn-graph-fit" title="Standard-Zoom"><i class="fa fa-crosshairs"></i></button>';
|
||||
print '</div>';
|
||||
print '</div>';
|
||||
} else {
|
||||
print '<div class="kundenkarte-tree-controls">';
|
||||
// Compact mode toggle (visible on mobile)
|
||||
print '<button type="button" class="kundenkarte-view-toggle" id="btn-compact-mode" title="Kompakte Ansicht">';
|
||||
print '<i class="fa fa-compress"></i> <span>Kompakt</span>';
|
||||
print '</button>';
|
||||
print '<button type="button" class="button small" id="btn-expand-all" title="'.$langs->trans('ExpandAll').'">';
|
||||
print '<i class="fa fa-expand"></i> '.$langs->trans('ExpandAll');
|
||||
print '</button>';
|
||||
print '<button type="button" class="button small" id="btn-collapse-all" title="'.$langs->trans('CollapseAll').'">';
|
||||
print '<i class="fa fa-compress"></i> '.$langs->trans('CollapseAll');
|
||||
print '</button>';
|
||||
if ($systemId > 0) {
|
||||
$exportUrl = dol_buildpath('/kundenkarte/ajax/export_tree_pdf.php', 1).'?socid='.$id.'&system='.$systemId;
|
||||
print '<a class="button small" href="'.$exportUrl.'" title="'.$langs->trans('ExportPDF').'" target="_blank">';
|
||||
print '<i class="fa fa-file-pdf-o"></i> PDF Export';
|
||||
print '</a>';
|
||||
}
|
||||
print '</div>';
|
||||
}
|
||||
print '</div>';
|
||||
}
|
||||
|
||||
print '</div>'; // End kundenkarte-system-tabs-wrapper
|
||||
|
|
@ -991,8 +1027,8 @@ if (empty($customerSystems)) {
|
|||
print '</div>';
|
||||
|
||||
} else {
|
||||
// Tree view
|
||||
if ($permissiontoadd) {
|
||||
// Listenansicht (Baum oder Graph)
|
||||
if ($permissiontoadd && $viewMode !== 'graph') {
|
||||
print '<div style="margin-bottom:15px;">';
|
||||
print '<a class="butAction" href="'.$_SERVER['PHP_SELF'].'?id='.$id.'&system='.$systemId.'&action=create">';
|
||||
print '<i class="fa fa-plus"></i> '.$langs->trans('AddElement');
|
||||
|
|
@ -1000,42 +1036,65 @@ if (empty($customerSystems)) {
|
|||
print '</div>';
|
||||
}
|
||||
|
||||
// Load tree
|
||||
$tree = $anlage->fetchTree($id, $systemId);
|
||||
if ($viewMode === 'graph' && $isTreeView) {
|
||||
// Graph-Ansicht: Container rendern, Daten werden per AJAX geladen
|
||||
$graphAjaxUrl = dol_buildpath('/kundenkarte/ajax/graph_data.php', 1);
|
||||
$graphSaveUrl = dol_buildpath('/kundenkarte/ajax/graph_save_positions.php', 1);
|
||||
$graphModuleUrl = dol_buildpath('/kundenkarte', 1);
|
||||
|
||||
// Pre-load all type fields for tooltip and tree display
|
||||
$typeFieldsMap = array();
|
||||
$sql = "SELECT f.*, f.fk_anlage_type FROM ".MAIN_DB_PREFIX."kundenkarte_anlage_type_field f WHERE f.active = 1 ORDER BY f.position ASC";
|
||||
$resql = $db->query($sql);
|
||||
if ($resql) {
|
||||
while ($obj = $db->fetch_object($resql)) {
|
||||
if (!isset($typeFieldsMap[$obj->fk_anlage_type])) {
|
||||
$typeFieldsMap[$obj->fk_anlage_type] = array();
|
||||
}
|
||||
$typeFieldsMap[$obj->fk_anlage_type][] = $obj;
|
||||
}
|
||||
$db->free($resql);
|
||||
}
|
||||
print '<div class="kundenkarte-graph-wrapper">';
|
||||
print '<div id="kundenkarte-graph-container"';
|
||||
print ' data-ajax-url="'.dol_escape_htmltag($graphAjaxUrl).'"';
|
||||
print ' data-save-url="'.dol_escape_htmltag($graphSaveUrl).'"';
|
||||
print ' data-module-url="'.dol_escape_htmltag($graphModuleUrl).'"';
|
||||
print ' data-socid="'.$id.'"';
|
||||
print '>';
|
||||
print '<div class="kundenkarte-graph-loading"><i class="fa fa-spinner fa-spin"></i> '.$langs->trans('GraphLoading').'</div>';
|
||||
print '</div>';
|
||||
|
||||
// Pre-load all connections for this customer/system
|
||||
dol_include_once('/kundenkarte/class/anlageconnection.class.php');
|
||||
$connObj = new AnlageConnection($db);
|
||||
$allConnections = $connObj->fetchBySociete($id, $systemId);
|
||||
// Index by target_id for quick lookup (connection shows ABOVE the target element)
|
||||
$connectionsByTarget = array();
|
||||
foreach ($allConnections as $conn) {
|
||||
if (!isset($connectionsByTarget[$conn->fk_target])) {
|
||||
$connectionsByTarget[$conn->fk_target] = array();
|
||||
}
|
||||
$connectionsByTarget[$conn->fk_target][] = $conn;
|
||||
}
|
||||
|
||||
if (!empty($tree)) {
|
||||
print '<div class="kundenkarte-tree" data-system="'.$systemId.'" data-socid="'.$id.'">';
|
||||
printTree($tree, $id, $systemId, $permissiontoadd, $permissiontodelete, $langs, 0, $typeFieldsMap, $connectionsByTarget);
|
||||
// Legende - wird dynamisch vom JS befüllt (Kabeltypen mit Farben)
|
||||
print '<div id="kundenkarte-graph-legend" class="kundenkarte-graph-legend"></div>';
|
||||
print '</div>';
|
||||
} else {
|
||||
print '<div class="opacitymedium">'.$langs->trans('NoInstallations').'</div>';
|
||||
// Baumansicht (klassisch)
|
||||
|
||||
// Load tree
|
||||
$tree = $anlage->fetchTree($id, $systemId);
|
||||
|
||||
// Pre-load all type fields for tooltip and tree display
|
||||
$typeFieldsMap = array();
|
||||
$sql = "SELECT f.*, f.fk_anlage_type FROM ".MAIN_DB_PREFIX."kundenkarte_anlage_type_field f WHERE f.active = 1 ORDER BY f.position ASC";
|
||||
$resql = $db->query($sql);
|
||||
if ($resql) {
|
||||
while ($obj = $db->fetch_object($resql)) {
|
||||
if (!isset($typeFieldsMap[$obj->fk_anlage_type])) {
|
||||
$typeFieldsMap[$obj->fk_anlage_type] = array();
|
||||
}
|
||||
$typeFieldsMap[$obj->fk_anlage_type][] = $obj;
|
||||
}
|
||||
$db->free($resql);
|
||||
}
|
||||
|
||||
// Pre-load all connections for this customer/system
|
||||
dol_include_once('/kundenkarte/class/anlageconnection.class.php');
|
||||
$connObj = new AnlageConnection($db);
|
||||
$allConnections = $connObj->fetchBySociete($id, $systemId);
|
||||
// Index by target_id for quick lookup (connection shows ABOVE the target element)
|
||||
$connectionsByTarget = array();
|
||||
foreach ($allConnections as $conn) {
|
||||
if (!isset($connectionsByTarget[$conn->fk_target])) {
|
||||
$connectionsByTarget[$conn->fk_target] = array();
|
||||
}
|
||||
$connectionsByTarget[$conn->fk_target][] = $conn;
|
||||
}
|
||||
|
||||
if (!empty($tree)) {
|
||||
print '<div class="kundenkarte-tree" data-system="'.$systemId.'" data-socid="'.$id.'">';
|
||||
printTree($tree, $id, $systemId, $permissiontoadd, $permissiontodelete, $langs, 0, $typeFieldsMap, $connectionsByTarget);
|
||||
print '</div>';
|
||||
} else {
|
||||
print '<div class="opacitymedium">'.$langs->trans('NoInstallations').'</div>';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -364,7 +364,24 @@ if ($action == 'togglepin' && $permissiontoadd) {
|
|||
*/
|
||||
|
||||
$title = $langs->trans('TechnicalInstallations').' - '.$object->getFullName($langs);
|
||||
llxHeader('', $title, '', '', 0, 0, array('/kundenkarte/js/pathfinding.min.js', '/kundenkarte/js/kundenkarte.js?v='.time()), array('/kundenkarte/css/kundenkarte.css?v='.time()));
|
||||
|
||||
// Ansichtsmodus (Admin-Setting)
|
||||
$viewMode = getDolGlobalString('KUNDENKARTE_DEFAULT_VIEW', 'tree');
|
||||
|
||||
$jsFiles = array('/kundenkarte/js/kundenkarte.js?v='.time());
|
||||
$cssFiles = array('/kundenkarte/css/kundenkarte.css?v='.time());
|
||||
|
||||
if ($viewMode === 'graph') {
|
||||
$jsFiles[] = '/kundenkarte/js/dagre.min.js';
|
||||
$jsFiles[] = '/kundenkarte/js/cytoscape.min.js';
|
||||
$jsFiles[] = '/kundenkarte/js/cytoscape-dagre.js';
|
||||
$jsFiles[] = '/kundenkarte/js/kundenkarte_cytoscape.js?v='.time();
|
||||
$cssFiles[] = '/kundenkarte/css/kundenkarte_cytoscape.css?v='.time();
|
||||
} else {
|
||||
array_unshift($jsFiles, '/kundenkarte/js/pathfinding.min.js');
|
||||
}
|
||||
|
||||
llxHeader('', $title, '', '', 0, 0, $jsFiles, $cssFiles);
|
||||
|
||||
// Prepare tabs
|
||||
$head = contact_prepare_head($object);
|
||||
|
|
@ -451,24 +468,43 @@ print '</div>';
|
|||
// Expand/Collapse buttons (only in tree view, not in create/edit/view/copy)
|
||||
$isTreeView = !in_array($action, array('create', 'edit', 'view', 'copy'));
|
||||
if ($isTreeView) {
|
||||
print '<div class="kundenkarte-tree-controls">';
|
||||
// Compact mode toggle (visible on mobile)
|
||||
print '<button type="button" class="kundenkarte-view-toggle" id="btn-compact-mode" title="Kompakte Ansicht">';
|
||||
print '<i class="fa fa-compress"></i> <span>Kompakt</span>';
|
||||
print '</button>';
|
||||
print '<button type="button" class="button small" id="btn-expand-all" title="'.$langs->trans('ExpandAll').'">';
|
||||
print '<i class="fa fa-expand"></i> '.$langs->trans('ExpandAll');
|
||||
print '</button>';
|
||||
print '<button type="button" class="button small" id="btn-collapse-all" title="'.$langs->trans('CollapseAll').'">';
|
||||
print '<i class="fa fa-compress"></i> '.$langs->trans('CollapseAll');
|
||||
print '</button>';
|
||||
if ($systemId > 0) {
|
||||
$exportUrl = dol_buildpath('/kundenkarte/ajax/export_tree_pdf.php', 1).'?socid='.$object->socid.'&contactid='.$id.'&system='.$systemId;
|
||||
print '<a class="button small" href="'.$exportUrl.'" title="'.$langs->trans('ExportPDF').'" target="_blank">';
|
||||
print '<i class="fa fa-file-pdf-o"></i> PDF Export';
|
||||
print '</a>';
|
||||
if ($viewMode === 'graph') {
|
||||
// Graph Controls: Aktionen + Zoom
|
||||
print '<div class="kundenkarte-graph-toolbar">';
|
||||
if ($user->hasRight('kundenkarte', 'write')) {
|
||||
print '<div class="kundenkarte-graph-actions">';
|
||||
print '<a class="button small" href="'.$_SERVER['PHP_SELF'].'?id='.$id.'&action=create&system='.$systemId.'"><i class="fa fa-plus"></i> '.$langs->trans('AddElement').'</a>';
|
||||
print '<a class="button small" href="'.dol_buildpath('/kundenkarte/anlage_connection.php', 1).'?socid='.$object->socid.'&contactid='.$id.'&system_id='.$systemId.'&action=create"><i class="fa fa-plug"></i> '.$langs->trans('AddConnection').'</a>';
|
||||
print '</div>';
|
||||
}
|
||||
print '<div class="kundenkarte-graph-zoom-controls">';
|
||||
print '<button type="button" class="button small" id="btn-graph-reset-layout" title="Layout zurücksetzen"><i class="fa fa-undo"></i></button>';
|
||||
print '<button type="button" class="button small" id="btn-graph-wheel-zoom" title="Mausrad-Zoom ein"><i class="fa fa-mouse-pointer"></i></button>';
|
||||
print '<button type="button" class="button small" id="btn-graph-zoom-in" title="Zoom +"><i class="fa fa-search-plus"></i></button>';
|
||||
print '<button type="button" class="button small" id="btn-graph-zoom-out" title="Zoom -"><i class="fa fa-search-minus"></i></button>';
|
||||
print '<button type="button" class="button small" id="btn-graph-fit" title="Standard-Zoom"><i class="fa fa-crosshairs"></i></button>';
|
||||
print '</div>';
|
||||
print '</div>';
|
||||
} else {
|
||||
print '<div class="kundenkarte-tree-controls">';
|
||||
// Compact mode toggle (visible on mobile)
|
||||
print '<button type="button" class="kundenkarte-view-toggle" id="btn-compact-mode" title="Kompakte Ansicht">';
|
||||
print '<i class="fa fa-compress"></i> <span>Kompakt</span>';
|
||||
print '</button>';
|
||||
print '<button type="button" class="button small" id="btn-expand-all" title="'.$langs->trans('ExpandAll').'">';
|
||||
print '<i class="fa fa-expand"></i> '.$langs->trans('ExpandAll');
|
||||
print '</button>';
|
||||
print '<button type="button" class="button small" id="btn-collapse-all" title="'.$langs->trans('CollapseAll').'">';
|
||||
print '<i class="fa fa-compress"></i> '.$langs->trans('CollapseAll');
|
||||
print '</button>';
|
||||
if ($systemId > 0) {
|
||||
$exportUrl = dol_buildpath('/kundenkarte/ajax/export_tree_pdf.php', 1).'?socid='.$object->socid.'&contactid='.$id.'&system='.$systemId;
|
||||
print '<a class="button small" href="'.$exportUrl.'" title="'.$langs->trans('ExportPDF').'" target="_blank">';
|
||||
print '<i class="fa fa-file-pdf-o"></i> PDF Export';
|
||||
print '</a>';
|
||||
}
|
||||
print '</div>';
|
||||
}
|
||||
print '</div>';
|
||||
}
|
||||
|
||||
print '</div>'; // End kundenkarte-system-tabs-wrapper
|
||||
|
|
@ -990,8 +1026,8 @@ if (empty($customerSystems)) {
|
|||
print '</div>';
|
||||
|
||||
} else {
|
||||
// Tree view
|
||||
if ($permissiontoadd) {
|
||||
// Listenansicht (Baum oder Graph)
|
||||
if ($permissiontoadd && $viewMode !== 'graph') {
|
||||
print '<div style="margin-bottom:15px;">';
|
||||
print '<a class="butAction" href="'.$_SERVER['PHP_SELF'].'?id='.$id.'&system='.$systemId.'&action=create">';
|
||||
print '<i class="fa fa-plus"></i> '.$langs->trans('AddElement');
|
||||
|
|
@ -999,42 +1035,66 @@ if (empty($customerSystems)) {
|
|||
print '</div>';
|
||||
}
|
||||
|
||||
// Load tree for this contact
|
||||
$tree = $anlage->fetchTreeByContact($object->socid, $id, $systemId);
|
||||
if ($viewMode === 'graph' && $isTreeView) {
|
||||
// Graph-Ansicht: Container rendern, Daten werden per AJAX geladen
|
||||
$graphAjaxUrl = dol_buildpath('/kundenkarte/ajax/graph_data.php', 1);
|
||||
$graphSaveUrl = dol_buildpath('/kundenkarte/ajax/graph_save_positions.php', 1);
|
||||
$graphModuleUrl = dol_buildpath('/kundenkarte', 1);
|
||||
|
||||
// Pre-load all type fields for tooltip and tree display
|
||||
$typeFieldsMap = array();
|
||||
$sql = "SELECT f.*, f.fk_anlage_type FROM ".MAIN_DB_PREFIX."kundenkarte_anlage_type_field f WHERE f.active = 1 ORDER BY f.position ASC";
|
||||
$resql = $db->query($sql);
|
||||
if ($resql) {
|
||||
while ($obj = $db->fetch_object($resql)) {
|
||||
if (!isset($typeFieldsMap[$obj->fk_anlage_type])) {
|
||||
$typeFieldsMap[$obj->fk_anlage_type] = array();
|
||||
}
|
||||
$typeFieldsMap[$obj->fk_anlage_type][] = $obj;
|
||||
}
|
||||
$db->free($resql);
|
||||
}
|
||||
print '<div class="kundenkarte-graph-wrapper">';
|
||||
print '<div id="kundenkarte-graph-container"';
|
||||
print ' data-ajax-url="'.dol_escape_htmltag($graphAjaxUrl).'"';
|
||||
print ' data-save-url="'.dol_escape_htmltag($graphSaveUrl).'"';
|
||||
print ' data-module-url="'.dol_escape_htmltag($graphModuleUrl).'"';
|
||||
print ' data-socid="'.$object->socid.'"';
|
||||
print ' data-contactid="'.$id.'"';
|
||||
print '>';
|
||||
print '<div class="kundenkarte-graph-loading"><i class="fa fa-spinner fa-spin"></i> '.$langs->trans('GraphLoading').'</div>';
|
||||
print '</div>';
|
||||
|
||||
// Pre-load all connections for this contact/system
|
||||
dol_include_once('/kundenkarte/class/anlageconnection.class.php');
|
||||
$connObj = new AnlageConnection($db);
|
||||
$allConnections = $connObj->fetchBySociete($object->socid, $systemId);
|
||||
// Index by target_id for quick lookup (connection shows ABOVE the target element)
|
||||
$connectionsByTarget = array();
|
||||
foreach ($allConnections as $conn) {
|
||||
if (!isset($connectionsByTarget[$conn->fk_target])) {
|
||||
$connectionsByTarget[$conn->fk_target] = array();
|
||||
}
|
||||
$connectionsByTarget[$conn->fk_target][] = $conn;
|
||||
}
|
||||
|
||||
if (!empty($tree)) {
|
||||
print '<div class="kundenkarte-tree" data-system="'.$systemId.'" data-socid="'.$object->socid.'">';
|
||||
printTree($tree, $id, $systemId, $permissiontoadd, $permissiontodelete, $langs, 0, $typeFieldsMap, $connectionsByTarget);
|
||||
// Legende - wird dynamisch vom JS befüllt (Kabeltypen mit Farben)
|
||||
print '<div id="kundenkarte-graph-legend" class="kundenkarte-graph-legend"></div>';
|
||||
print '</div>';
|
||||
} else {
|
||||
print '<div class="opacitymedium">'.$langs->trans('NoInstallations').'</div>';
|
||||
// Baumansicht (klassisch)
|
||||
|
||||
// Load tree for this contact
|
||||
$tree = $anlage->fetchTreeByContact($object->socid, $id, $systemId);
|
||||
|
||||
// Pre-load all type fields for tooltip and tree display
|
||||
$typeFieldsMap = array();
|
||||
$sql = "SELECT f.*, f.fk_anlage_type FROM ".MAIN_DB_PREFIX."kundenkarte_anlage_type_field f WHERE f.active = 1 ORDER BY f.position ASC";
|
||||
$resql = $db->query($sql);
|
||||
if ($resql) {
|
||||
while ($obj = $db->fetch_object($resql)) {
|
||||
if (!isset($typeFieldsMap[$obj->fk_anlage_type])) {
|
||||
$typeFieldsMap[$obj->fk_anlage_type] = array();
|
||||
}
|
||||
$typeFieldsMap[$obj->fk_anlage_type][] = $obj;
|
||||
}
|
||||
$db->free($resql);
|
||||
}
|
||||
|
||||
// Pre-load all connections for this contact/system
|
||||
dol_include_once('/kundenkarte/class/anlageconnection.class.php');
|
||||
$connObj = new AnlageConnection($db);
|
||||
$allConnections = $connObj->fetchBySociete($object->socid, $systemId);
|
||||
// Index by target_id for quick lookup (connection shows ABOVE the target element)
|
||||
$connectionsByTarget = array();
|
||||
foreach ($allConnections as $conn) {
|
||||
if (!isset($connectionsByTarget[$conn->fk_target])) {
|
||||
$connectionsByTarget[$conn->fk_target] = array();
|
||||
}
|
||||
$connectionsByTarget[$conn->fk_target][] = $conn;
|
||||
}
|
||||
|
||||
if (!empty($tree)) {
|
||||
print '<div class="kundenkarte-tree" data-system="'.$systemId.'" data-socid="'.$object->socid.'">';
|
||||
printTree($tree, $id, $systemId, $permissiontoadd, $permissiontodelete, $langs, 0, $typeFieldsMap, $connectionsByTarget);
|
||||
print '</div>';
|
||||
} else {
|
||||
print '<div class="opacitymedium">'.$langs->trans('NoInstallations').'</div>';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1146,7 +1206,7 @@ function printTree($nodes, $contactid, $systemId, $canEdit, $canDelete, $langs,
|
|||
$mainText = $conn->label ? $conn->label : $cableInfo;
|
||||
$badgeText = $conn->label ? $cableInfo : '';
|
||||
|
||||
$connEditUrl = dol_buildpath('/kundenkarte/anlage_connection.php', 1).'?id='.$conn->id.'&socid='.$node->fk_soc.'&system_id='.$systemId;
|
||||
$connEditUrl = dol_buildpath('/kundenkarte/anlage_connection.php', 1).'?id='.$conn->id.'&socid='.$node->fk_soc.'&contactid='.$id.'&system_id='.$systemId;
|
||||
print '<a href="'.$connEditUrl.'" class="kundenkarte-tree-conn">';
|
||||
print '<span class="conn-icon"><i class="fa fa-plug"></i></span>';
|
||||
if ($mainText) {
|
||||
|
|
@ -1394,7 +1454,7 @@ function printTreeWithCableLines($nodes, $contactid, $systemId, $canEdit, $canDe
|
|||
$mainText = $conn->label ? $conn->label : $cableInfo;
|
||||
$badgeText = $conn->label ? $cableInfo : '';
|
||||
|
||||
$connEditUrl = dol_buildpath('/kundenkarte/anlage_connection.php', 1).'?id='.$conn->id.'&socid='.$node->fk_soc.'&system_id='.$systemId;
|
||||
$connEditUrl = dol_buildpath('/kundenkarte/anlage_connection.php', 1).'?id='.$conn->id.'&socid='.$node->fk_soc.'&contactid='.$id.'&system_id='.$systemId;
|
||||
print '<div class="kundenkarte-tree-row">';
|
||||
|
||||
// Draw vertical line columns (for cables passing through)
|
||||
|
|
|
|||
Loading…
Reference in a new issue