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:
Eduard Wisch 2026-02-22 06:20:14 +01:00
parent 0a151270ec
commit 840c0132c3
20 changed files with 15021 additions and 127 deletions

View file

@ -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

View file

@ -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
View 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);

View 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);

View file

@ -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;
$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, $prefix . ' ');
$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();

View file

@ -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;

View file

@ -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.

View 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

File diff suppressed because it is too large Load diff

View 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
View 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

File diff suppressed because one or more lines are too long

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
View 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

File diff suppressed because it is too large Load diff

View file

@ -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

View file

@ -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

View file

@ -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),

View file

@ -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,6 +469,24 @@ 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) {
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">';
@ -470,6 +505,7 @@ if ($isTreeView) {
print '</a>';
}
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,6 +1036,28 @@ if (empty($customerSystems)) {
print '</div>';
}
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);
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>';
// Legende - wird dynamisch vom JS befüllt (Kabeltypen mit Farben)
print '<div id="kundenkarte-graph-legend" class="kundenkarte-graph-legend"></div>';
print '</div>';
} else {
// Baumansicht (klassisch)
// Load tree
$tree = $anlage->fetchTree($id, $systemId);
@ -1038,6 +1096,7 @@ if (empty($customerSystems)) {
print '<div class="opacitymedium">'.$langs->trans('NoInstallations').'</div>';
}
}
}
}
print '</div>';

View file

@ -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,6 +468,24 @@ 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) {
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">';
@ -469,6 +504,7 @@ if ($isTreeView) {
print '</a>';
}
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,6 +1035,29 @@ if (empty($customerSystems)) {
print '</div>';
}
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);
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>';
// Legende - wird dynamisch vom JS befüllt (Kabeltypen mit Farben)
print '<div id="kundenkarte-graph-legend" class="kundenkarte-graph-legend"></div>';
print '</div>';
} else {
// Baumansicht (klassisch)
// Load tree for this contact
$tree = $anlage->fetchTreeByContact($object->socid, $id, $systemId);
@ -1037,6 +1096,7 @@ if (empty($customerSystems)) {
print '<div class="opacitymedium">'.$langs->trans('NoInstallations').'</div>';
}
}
}
}
print '</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)