2026-03-14 20:50:09 +01:00
|
|
|
"""
|
|
|
|
|
XslDependencyDialog - Dialog zur Anzeige des XSL-Abhängigkeitsgraphen.
|
|
|
|
|
|
|
|
|
|
Zeigt alle XSL-Dateien im konfigurierten Verzeichnis mit ihren
|
|
|
|
|
Import/Include-Beziehungen (vorwärts und rückwärts).
|
|
|
|
|
Bietet zwei Ansichten: Baumansicht und interaktiver Netzwerkgraph (vis.js).
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
import logging
|
|
|
|
|
import tempfile
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
from PySide6.QtCore import QUrl, Qt
|
|
|
|
|
from PySide6.QtGui import QIcon
|
2026-03-21 19:32:29 +01:00
|
|
|
from PySide6.QtWidgets import (
|
|
|
|
|
QComboBox,
|
|
|
|
|
QDialog,
|
|
|
|
|
QDoubleSpinBox,
|
|
|
|
|
QFormLayout,
|
|
|
|
|
QLabel,
|
|
|
|
|
QSpinBox,
|
|
|
|
|
QStackedWidget,
|
|
|
|
|
QTreeWidgetItem,
|
|
|
|
|
QWidget,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
from conf import (
|
|
|
|
|
GraphLayout,
|
|
|
|
|
GraphLayoutSettings,
|
|
|
|
|
HierarchicalDirection,
|
|
|
|
|
HierarchicalSortMethod,
|
|
|
|
|
app_settings,
|
|
|
|
|
)
|
2026-03-14 20:50:09 +01:00
|
|
|
from ui.XslDependencyDialog_ui import Ui_XslDependencyDialog
|
|
|
|
|
from xsl_dependencies import XslDependencyGraph
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
2026-03-21 19:32:29 +01:00
|
|
|
|
|
|
|
|
def _build_physics_options_dict(s: GraphLayoutSettings) -> str:
|
|
|
|
|
"""
|
|
|
|
|
Generiert den vis.js Physics/Layout-Optionsblock als JSON-String.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
s: Aktuelle GraphLayoutSettings
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
str: JSON-String für network.setOptions()
|
|
|
|
|
"""
|
|
|
|
|
if s.layout == GraphLayout.HIERARCHICAL:
|
|
|
|
|
return json.dumps(
|
|
|
|
|
{
|
|
|
|
|
"physics": {"enabled": False},
|
|
|
|
|
"layout": {
|
|
|
|
|
"hierarchical": {
|
|
|
|
|
"enabled": True,
|
|
|
|
|
"direction": s.hi_direction.value,
|
|
|
|
|
"sortMethod": s.hi_sort_method.value,
|
|
|
|
|
"levelSeparation": s.hi_level_separation,
|
|
|
|
|
"nodeSpacing": s.hi_node_spacing,
|
|
|
|
|
"treeSpacing": s.hi_tree_spacing,
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
physics_params: dict = {"solver": s.layout.value}
|
|
|
|
|
|
|
|
|
|
if s.layout == GraphLayout.BARNES_HUT:
|
|
|
|
|
physics_params["barnesHut"] = {
|
|
|
|
|
"gravitationalConstant": s.bh_gravitational_constant,
|
|
|
|
|
"centralGravity": s.bh_central_gravity,
|
|
|
|
|
"springLength": s.bh_spring_length,
|
|
|
|
|
"springConstant": s.bh_spring_constant,
|
|
|
|
|
"damping": s.bh_damping,
|
|
|
|
|
}
|
|
|
|
|
elif s.layout == GraphLayout.FORCE_ATLAS2:
|
|
|
|
|
physics_params["forceAtlas2Based"] = {
|
|
|
|
|
"gravitationalConstant": s.fa_gravitational_constant,
|
|
|
|
|
"centralGravity": s.fa_central_gravity,
|
|
|
|
|
"springLength": s.fa_spring_length,
|
|
|
|
|
"springConstant": s.fa_spring_constant,
|
|
|
|
|
"damping": s.fa_damping,
|
|
|
|
|
}
|
|
|
|
|
elif s.layout == GraphLayout.REPULSION:
|
|
|
|
|
physics_params["repulsion"] = {
|
|
|
|
|
"nodeDistance": s.re_node_distance,
|
|
|
|
|
"centralGravity": s.re_central_gravity,
|
|
|
|
|
"springLength": s.re_spring_length,
|
|
|
|
|
"springConstant": s.re_spring_constant,
|
|
|
|
|
"damping": s.re_damping,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
physics_params["stabilization"] = {"enabled": True, "iterations": 200, "updateInterval": 25}
|
|
|
|
|
|
|
|
|
|
return json.dumps({"physics": physics_params, "layout": {"hierarchical": {"enabled": False}}})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_set_options_js(s: GraphLayoutSettings) -> str:
|
|
|
|
|
"""
|
|
|
|
|
Generiert den JavaScript-Aufruf network.setOptions(...) als String.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
s: Aktuelle GraphLayoutSettings
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
str: JavaScript-Code für runJavaScript()
|
|
|
|
|
"""
|
|
|
|
|
return f"network.setOptions({_build_physics_options_dict(s)});"
|
|
|
|
|
|
|
|
|
|
|
2026-03-14 20:50:09 +01:00
|
|
|
# Prüfe ob QWebEngineView verfügbar ist
|
|
|
|
|
try:
|
|
|
|
|
from PySide6.QtWebEngineWidgets import QWebEngineView
|
|
|
|
|
|
|
|
|
|
HAS_WEBENGINE = True
|
|
|
|
|
except ImportError:
|
|
|
|
|
HAS_WEBENGINE = False
|
|
|
|
|
logger.warning("PySide6-WebEngine nicht verfügbar — Netzwerkgraph-Tab deaktiviert")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class XslDependencyDialog(QDialog):
|
|
|
|
|
"""Dialog zur Anzeige des vollständigen XSL-Abhängigkeitsgraphen."""
|
|
|
|
|
|
|
|
|
|
def __init__(self, parent, xsl_root_dir: Path, dependency_graph: XslDependencyGraph):
|
|
|
|
|
"""
|
|
|
|
|
Args:
|
|
|
|
|
parent: Eltern-Widget
|
|
|
|
|
xsl_root_dir: Wurzelverzeichnis der XSL-Dateien
|
|
|
|
|
dependency_graph: XSL-Abhängigkeitsgraph-Instanz
|
|
|
|
|
"""
|
|
|
|
|
super().__init__(parent)
|
|
|
|
|
self.xsl_root_dir = xsl_root_dir
|
|
|
|
|
self.dependency_graph = dependency_graph
|
|
|
|
|
self._web_view: "QWebEngineView | None" = None
|
|
|
|
|
self._graph_loaded = False
|
|
|
|
|
self._temp_html_file: tempfile.NamedTemporaryFile | None = None
|
|
|
|
|
|
2026-03-16 21:15:16 +01:00
|
|
|
self._sidebar_visible = False
|
|
|
|
|
|
2026-03-14 20:50:09 +01:00
|
|
|
self.ui = Ui_XslDependencyDialog()
|
|
|
|
|
self.ui.setupUi(self)
|
|
|
|
|
|
2026-03-16 21:15:16 +01:00
|
|
|
# Sidebar initial ausblenden
|
|
|
|
|
self.ui.settingsButton.setIcon(QIcon.fromTheme("preferences-system"))
|
|
|
|
|
self.ui.sidebarWidget.setVisible(False)
|
|
|
|
|
self.ui.mainSplitter.setSizes([1000, 0])
|
|
|
|
|
self.ui.settingsButton.clicked.connect(self._toggle_sidebar)
|
|
|
|
|
|
2026-03-14 20:50:09 +01:00
|
|
|
# Netzwerkgraph-Tab ausblenden wenn WebEngine nicht verfügbar
|
|
|
|
|
if not HAS_WEBENGINE:
|
|
|
|
|
self.ui.tabWidget.removeTab(1)
|
|
|
|
|
|
|
|
|
|
# Spalten konfigurieren
|
|
|
|
|
self.ui.fileTree.setHeaderLabels(["XSL-Datei", "Imports", "Importiert von"])
|
|
|
|
|
self.ui.fileTree.setColumnWidth(0, 300)
|
|
|
|
|
self.ui.fileTree.setColumnWidth(1, 60)
|
|
|
|
|
self.ui.fileTree.setColumnWidth(2, 60)
|
|
|
|
|
|
|
|
|
|
self.ui.depTree.setHeaderLabels(["Datei", "Typ"])
|
|
|
|
|
self.ui.depTree.setColumnWidth(0, 350)
|
|
|
|
|
|
|
|
|
|
# Graph aufbauen und anzeigen
|
|
|
|
|
self._full_graph = self.dependency_graph.build_full_graph(self.xsl_root_dir)
|
|
|
|
|
self._reverse_map = self._build_reverse_map()
|
|
|
|
|
self._populate_file_tree()
|
|
|
|
|
|
|
|
|
|
# Signale verbinden
|
|
|
|
|
self.ui.fileTree.currentItemChanged.connect(self._on_file_selected)
|
|
|
|
|
self.ui.searchEdit.textChanged.connect(self._on_search_changed)
|
|
|
|
|
self.ui.tabWidget.currentChanged.connect(self._on_tab_changed)
|
2026-03-21 19:32:29 +01:00
|
|
|
self.ui.graphFilterConnectedCheck.toggled.connect(lambda: self._on_search_changed(self.ui.searchEdit.text()))
|
|
|
|
|
|
|
|
|
|
# Layout-Controls in graphSettingsPage einfügen und Einstellungen wiederherstellen
|
|
|
|
|
self._build_layout_controls()
|
|
|
|
|
self._restore_graph_layout_settings()
|
2026-03-14 20:50:09 +01:00
|
|
|
|
2026-03-21 19:32:29 +01:00
|
|
|
def reject(self):
|
|
|
|
|
"""Speichert Layout-Einstellungen und räumt temporäre Dateien und QWebEngineView auf."""
|
|
|
|
|
self._save_graph_layout_settings()
|
2026-03-14 20:50:09 +01:00
|
|
|
if self._web_view is not None:
|
|
|
|
|
self._web_view.setUrl(QUrl("about:blank"))
|
|
|
|
|
self._web_view.deleteLater()
|
|
|
|
|
self._web_view = None
|
|
|
|
|
if self._temp_html_file is not None:
|
|
|
|
|
try:
|
|
|
|
|
Path(self._temp_html_file.name).unlink(missing_ok=True)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
self._temp_html_file = None
|
2026-03-21 19:32:29 +01:00
|
|
|
super().reject()
|
2026-03-14 20:50:09 +01:00
|
|
|
|
|
|
|
|
def _build_reverse_map(self) -> dict[Path, set[Path]]:
|
|
|
|
|
"""Baut eine Reverse-Map auf: Welche Dateien importieren eine gegebene Datei?"""
|
|
|
|
|
reverse: dict[Path, set[Path]] = {}
|
|
|
|
|
for xsl_file, deps in self._full_graph.items():
|
|
|
|
|
for dep in deps:
|
|
|
|
|
if dep not in reverse:
|
|
|
|
|
reverse[dep] = set()
|
|
|
|
|
reverse[dep].add(xsl_file)
|
|
|
|
|
return reverse
|
|
|
|
|
|
|
|
|
|
def _rel_path(self, abs_path: Path) -> str:
|
|
|
|
|
"""Gibt den relativen Pfad zur XSL-Root zurück."""
|
|
|
|
|
try:
|
|
|
|
|
return str(abs_path.relative_to(self.xsl_root_dir))
|
|
|
|
|
except ValueError:
|
|
|
|
|
return abs_path.name
|
|
|
|
|
|
|
|
|
|
def _populate_file_tree(self):
|
|
|
|
|
"""Befüllt den Dateibaum mit allen XSL-Dateien und Abhängigkeitszahlen."""
|
|
|
|
|
self.ui.fileTree.clear()
|
|
|
|
|
|
|
|
|
|
xsl_icon = QIcon.fromTheme("text-x-generic")
|
|
|
|
|
|
|
|
|
|
for xsl_file in sorted(self._full_graph.keys(), key=lambda p: self._rel_path(p).lower()):
|
|
|
|
|
deps_count = len(self._full_graph.get(xsl_file, set()))
|
|
|
|
|
reverse_count = len(self._reverse_map.get(xsl_file, set()))
|
|
|
|
|
|
|
|
|
|
item = QTreeWidgetItem()
|
|
|
|
|
item.setText(0, self._rel_path(xsl_file))
|
|
|
|
|
item.setText(1, str(deps_count) if deps_count > 0 else "")
|
|
|
|
|
item.setText(2, str(reverse_count) if reverse_count > 0 else "")
|
|
|
|
|
item.setData(0, Qt.ItemDataRole.UserRole, xsl_file)
|
|
|
|
|
item.setIcon(0, xsl_icon)
|
|
|
|
|
|
|
|
|
|
# Hervorhebung für Dateien mit vielen Abhängigkeiten
|
|
|
|
|
if deps_count > 5 or reverse_count > 10:
|
|
|
|
|
font = item.font(0)
|
|
|
|
|
font.setBold(True)
|
|
|
|
|
item.setFont(0, font)
|
|
|
|
|
|
|
|
|
|
self.ui.fileTree.addTopLevelItem(item)
|
|
|
|
|
|
|
|
|
|
total = len(self._full_graph)
|
|
|
|
|
with_deps = sum(1 for deps in self._full_graph.values() if deps)
|
|
|
|
|
self.ui.statusLabel.setText(f"{total} XSL-Dateien, davon {with_deps} mit Abhängigkeiten")
|
|
|
|
|
|
|
|
|
|
def _on_file_selected(self, current: QTreeWidgetItem | None, _previous: QTreeWidgetItem | None):
|
|
|
|
|
"""Zeigt Abhängigkeitsdetails für die ausgewählte Datei."""
|
|
|
|
|
self.ui.depTree.clear()
|
|
|
|
|
|
|
|
|
|
if current is None:
|
|
|
|
|
self.ui.rightLabel.setText("Abhängigkeiten")
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
xsl_file: Path = current.data(0, Qt.ItemDataRole.UserRole)
|
|
|
|
|
if xsl_file is None:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
rel_name = self._rel_path(xsl_file)
|
|
|
|
|
self.ui.rightLabel.setText(f"Abhängigkeiten: {rel_name}")
|
|
|
|
|
|
|
|
|
|
import_icon = QIcon.fromTheme("go-down")
|
|
|
|
|
imported_by_icon = QIcon.fromTheme("go-up")
|
|
|
|
|
|
|
|
|
|
# Sektion: "Importiert" (forward dependencies)
|
|
|
|
|
deps = self._full_graph.get(xsl_file, set())
|
|
|
|
|
if deps:
|
|
|
|
|
imports_root = QTreeWidgetItem()
|
|
|
|
|
imports_root.setText(0, f"Importiert ({len(deps)})")
|
|
|
|
|
imports_root.setIcon(0, import_icon)
|
|
|
|
|
font = imports_root.font(0)
|
|
|
|
|
font.setBold(True)
|
|
|
|
|
imports_root.setFont(0, font)
|
|
|
|
|
|
|
|
|
|
for dep in sorted(deps, key=lambda p: self._rel_path(p).lower()):
|
|
|
|
|
dep_item = QTreeWidgetItem()
|
|
|
|
|
dep_item.setText(0, self._rel_path(dep))
|
|
|
|
|
dep_item.setText(1, "import/include")
|
|
|
|
|
dep_item.setData(0, Qt.ItemDataRole.UserRole, dep)
|
|
|
|
|
imports_root.addChild(dep_item)
|
|
|
|
|
|
|
|
|
|
self.ui.depTree.addTopLevelItem(imports_root)
|
|
|
|
|
imports_root.setExpanded(True)
|
|
|
|
|
|
|
|
|
|
# Sektion: "Wird importiert von" (reverse dependencies)
|
|
|
|
|
reverse_deps = self._reverse_map.get(xsl_file, set())
|
|
|
|
|
if reverse_deps:
|
|
|
|
|
imported_by_root = QTreeWidgetItem()
|
|
|
|
|
imported_by_root.setText(0, f"Wird importiert von ({len(reverse_deps)})")
|
|
|
|
|
imported_by_root.setIcon(0, imported_by_icon)
|
|
|
|
|
font = imported_by_root.font(0)
|
|
|
|
|
font.setBold(True)
|
|
|
|
|
imported_by_root.setFont(0, font)
|
|
|
|
|
|
|
|
|
|
for rev in sorted(reverse_deps, key=lambda p: self._rel_path(p).lower()):
|
|
|
|
|
rev_item = QTreeWidgetItem()
|
|
|
|
|
rev_item.setText(0, self._rel_path(rev))
|
|
|
|
|
rev_item.setText(1, "importiert diese Datei")
|
|
|
|
|
rev_item.setData(0, Qt.ItemDataRole.UserRole, rev)
|
|
|
|
|
imported_by_root.addChild(rev_item)
|
|
|
|
|
|
|
|
|
|
self.ui.depTree.addTopLevelItem(imported_by_root)
|
|
|
|
|
imported_by_root.setExpanded(True)
|
|
|
|
|
|
|
|
|
|
if not deps and not reverse_deps:
|
|
|
|
|
no_deps_item = QTreeWidgetItem()
|
|
|
|
|
no_deps_item.setText(0, "Keine Abhängigkeiten")
|
|
|
|
|
self.ui.depTree.addTopLevelItem(no_deps_item)
|
|
|
|
|
|
|
|
|
|
def _on_search_changed(self, text: str):
|
2026-03-16 21:15:16 +01:00
|
|
|
"""Filtert je nach aktivem Tab die Dateiliste oder den Netzwerkgraph."""
|
|
|
|
|
current_tab = self.ui.tabWidget.currentIndex()
|
|
|
|
|
if current_tab == 0:
|
|
|
|
|
self._search_tree(text)
|
|
|
|
|
elif current_tab == 1 and self._web_view is not None:
|
|
|
|
|
self._search_graph(text)
|
|
|
|
|
|
|
|
|
|
def _search_tree(self, text: str):
|
2026-03-14 20:50:09 +01:00
|
|
|
"""Filtert die Dateiliste nach dem Suchbegriff."""
|
|
|
|
|
search_lower = text.lower()
|
|
|
|
|
for i in range(self.ui.fileTree.topLevelItemCount()):
|
|
|
|
|
item = self.ui.fileTree.topLevelItem(i)
|
2026-03-16 21:15:16 +01:00
|
|
|
matches = not search_lower or search_lower in item.text(0).lower()
|
2026-03-14 20:50:09 +01:00
|
|
|
item.setHidden(not matches)
|
|
|
|
|
|
2026-03-16 21:15:16 +01:00
|
|
|
def _search_graph(self, text: str):
|
|
|
|
|
"""Filtert oder dimmt Knoten im Netzwerkgraph je nach Checkbox-Einstellung."""
|
|
|
|
|
escaped = text.replace("\\", "\\\\").replace("'", "\\'")
|
|
|
|
|
hide_unrelated = str(self.ui.graphFilterConnectedCheck.isChecked()).lower()
|
|
|
|
|
self._web_view.page().runJavaScript(f"filterNodes('{escaped}', {hide_unrelated})")
|
|
|
|
|
|
|
|
|
|
# === Sidebar ===
|
|
|
|
|
|
|
|
|
|
def _toggle_sidebar(self):
|
|
|
|
|
"""Blendet die Einstellungs-Sidebar ein oder aus."""
|
|
|
|
|
self._sidebar_visible = not self._sidebar_visible
|
|
|
|
|
if self._sidebar_visible:
|
|
|
|
|
self.ui.sidebarWidget.setVisible(True)
|
|
|
|
|
self.ui.mainSplitter.setSizes([750, 250])
|
|
|
|
|
else:
|
|
|
|
|
self.ui.mainSplitter.setSizes([1000, 0])
|
|
|
|
|
self.ui.sidebarWidget.setVisible(False)
|
|
|
|
|
|
2026-03-21 19:32:29 +01:00
|
|
|
# === Graph-Layout-Einstellungen ===
|
|
|
|
|
|
|
|
|
|
def _build_layout_controls(self):
|
|
|
|
|
"""Erstellt programmatisch alle Layout-Steuerelemente in graphSettingsPage."""
|
|
|
|
|
layout = self.ui.graphSettingsLayout
|
|
|
|
|
|
|
|
|
|
# Spacer am Ende entfernen (wird am Schluss wieder angehängt)
|
|
|
|
|
layout.removeItem(self.ui.graphSettingsSpacer)
|
|
|
|
|
|
|
|
|
|
# ComboBox für Layout-Auswahl
|
|
|
|
|
layout.addWidget(QLabel("Layout-Algorithmus:"))
|
|
|
|
|
|
|
|
|
|
self._layout_combo = QComboBox()
|
|
|
|
|
self._layout_combo.addItem("Barnes-Hut", GraphLayout.BARNES_HUT)
|
|
|
|
|
self._layout_combo.addItem("ForceAtlas2", GraphLayout.FORCE_ATLAS2)
|
|
|
|
|
self._layout_combo.addItem("Repulsion", GraphLayout.REPULSION)
|
|
|
|
|
self._layout_combo.addItem("Hierarchisch", GraphLayout.HIERARCHICAL)
|
|
|
|
|
layout.addWidget(self._layout_combo)
|
|
|
|
|
|
|
|
|
|
# StackedWidget für layout-spezifische Parameter
|
|
|
|
|
self._params_stack = QStackedWidget()
|
|
|
|
|
layout.addWidget(self._params_stack)
|
|
|
|
|
|
|
|
|
|
# Seite 0: barnesHut
|
|
|
|
|
self._params_stack.addWidget(self._build_barnes_hut_page())
|
|
|
|
|
|
|
|
|
|
# Seite 1: forceAtlas2Based
|
|
|
|
|
self._params_stack.addWidget(self._build_force_atlas2_page())
|
|
|
|
|
|
|
|
|
|
# Seite 2: repulsion
|
|
|
|
|
self._params_stack.addWidget(self._build_repulsion_page())
|
|
|
|
|
|
|
|
|
|
# Seite 3: hierarchical
|
|
|
|
|
self._params_stack.addWidget(self._build_hierarchical_page())
|
|
|
|
|
|
|
|
|
|
# Spacer wieder an den Schluss
|
|
|
|
|
layout.addItem(self.ui.graphSettingsSpacer)
|
|
|
|
|
|
|
|
|
|
# Signale verbinden — live Update bei jeder Änderung
|
|
|
|
|
self._layout_combo.currentIndexChanged.connect(self._on_layout_changed)
|
|
|
|
|
for spinbox in (
|
|
|
|
|
self._bh_gravitational,
|
|
|
|
|
self._bh_spring_length,
|
|
|
|
|
self._fa_gravitational,
|
|
|
|
|
self._fa_spring_length,
|
|
|
|
|
self._re_node_distance,
|
|
|
|
|
self._re_spring_length,
|
|
|
|
|
self._hi_level_separation,
|
|
|
|
|
self._hi_node_spacing,
|
|
|
|
|
self._hi_tree_spacing,
|
|
|
|
|
):
|
|
|
|
|
spinbox.valueChanged.connect(self._apply_layout_to_graph)
|
|
|
|
|
for dspinbox in (
|
|
|
|
|
self._bh_central_gravity,
|
|
|
|
|
self._bh_spring_constant,
|
|
|
|
|
self._bh_damping,
|
|
|
|
|
self._fa_central_gravity,
|
|
|
|
|
self._fa_spring_constant,
|
|
|
|
|
self._fa_damping,
|
|
|
|
|
self._re_central_gravity,
|
|
|
|
|
self._re_spring_constant,
|
|
|
|
|
self._re_damping,
|
|
|
|
|
):
|
|
|
|
|
dspinbox.valueChanged.connect(self._apply_layout_to_graph)
|
|
|
|
|
self._hi_direction.currentIndexChanged.connect(self._apply_layout_to_graph)
|
|
|
|
|
self._hi_sort_method.currentIndexChanged.connect(self._apply_layout_to_graph)
|
|
|
|
|
|
|
|
|
|
def _build_barnes_hut_page(self) -> QWidget:
|
|
|
|
|
"""Erstellt die Parameter-Seite für das Barnes-Hut-Layout."""
|
|
|
|
|
page = QWidget()
|
|
|
|
|
form = QFormLayout(page)
|
|
|
|
|
self._bh_gravitational = QSpinBox()
|
|
|
|
|
self._bh_gravitational.setRange(-30000, 0)
|
|
|
|
|
self._bh_gravitational.setSingleStep(100)
|
|
|
|
|
self._bh_central_gravity = QDoubleSpinBox()
|
|
|
|
|
self._bh_central_gravity.setRange(0.0, 5.0)
|
|
|
|
|
self._bh_central_gravity.setSingleStep(0.1)
|
|
|
|
|
self._bh_central_gravity.setDecimals(2)
|
|
|
|
|
self._bh_spring_length = QSpinBox()
|
|
|
|
|
self._bh_spring_length.setRange(1, 1000)
|
|
|
|
|
self._bh_spring_constant = QDoubleSpinBox()
|
|
|
|
|
self._bh_spring_constant.setRange(0.0, 1.0)
|
|
|
|
|
self._bh_spring_constant.setSingleStep(0.01)
|
|
|
|
|
self._bh_spring_constant.setDecimals(3)
|
|
|
|
|
self._bh_damping = QDoubleSpinBox()
|
|
|
|
|
self._bh_damping.setRange(0.0, 1.0)
|
|
|
|
|
self._bh_damping.setSingleStep(0.01)
|
|
|
|
|
self._bh_damping.setDecimals(2)
|
|
|
|
|
form.addRow("Gravitation:", self._bh_gravitational)
|
|
|
|
|
form.addRow("Zentrale Gravitation:", self._bh_central_gravity)
|
|
|
|
|
form.addRow("Federlänge:", self._bh_spring_length)
|
|
|
|
|
form.addRow("Federkonstante:", self._bh_spring_constant)
|
|
|
|
|
form.addRow("Dämpfung:", self._bh_damping)
|
|
|
|
|
return page
|
|
|
|
|
|
|
|
|
|
def _build_force_atlas2_page(self) -> QWidget:
|
|
|
|
|
"""Erstellt die Parameter-Seite für das ForceAtlas2-Layout."""
|
|
|
|
|
page = QWidget()
|
|
|
|
|
form = QFormLayout(page)
|
|
|
|
|
self._fa_gravitational = QSpinBox()
|
|
|
|
|
self._fa_gravitational.setRange(-10000, 0)
|
|
|
|
|
self._fa_gravitational.setSingleStep(10)
|
|
|
|
|
self._fa_central_gravity = QDoubleSpinBox()
|
|
|
|
|
self._fa_central_gravity.setRange(0.0, 5.0)
|
|
|
|
|
self._fa_central_gravity.setSingleStep(0.01)
|
|
|
|
|
self._fa_central_gravity.setDecimals(3)
|
|
|
|
|
self._fa_spring_length = QSpinBox()
|
|
|
|
|
self._fa_spring_length.setRange(1, 1000)
|
|
|
|
|
self._fa_spring_constant = QDoubleSpinBox()
|
|
|
|
|
self._fa_spring_constant.setRange(0.0, 1.0)
|
|
|
|
|
self._fa_spring_constant.setSingleStep(0.01)
|
|
|
|
|
self._fa_spring_constant.setDecimals(3)
|
|
|
|
|
self._fa_damping = QDoubleSpinBox()
|
|
|
|
|
self._fa_damping.setRange(0.0, 1.0)
|
|
|
|
|
self._fa_damping.setSingleStep(0.01)
|
|
|
|
|
self._fa_damping.setDecimals(2)
|
|
|
|
|
form.addRow("Gravitation:", self._fa_gravitational)
|
|
|
|
|
form.addRow("Zentrale Gravitation:", self._fa_central_gravity)
|
|
|
|
|
form.addRow("Federlänge:", self._fa_spring_length)
|
|
|
|
|
form.addRow("Federkonstante:", self._fa_spring_constant)
|
|
|
|
|
form.addRow("Dämpfung:", self._fa_damping)
|
|
|
|
|
return page
|
|
|
|
|
|
|
|
|
|
def _build_repulsion_page(self) -> QWidget:
|
|
|
|
|
"""Erstellt die Parameter-Seite für das Repulsion-Layout."""
|
|
|
|
|
page = QWidget()
|
|
|
|
|
form = QFormLayout(page)
|
|
|
|
|
self._re_node_distance = QSpinBox()
|
|
|
|
|
self._re_node_distance.setRange(1, 1000)
|
|
|
|
|
self._re_central_gravity = QDoubleSpinBox()
|
|
|
|
|
self._re_central_gravity.setRange(0.0, 5.0)
|
|
|
|
|
self._re_central_gravity.setSingleStep(0.1)
|
|
|
|
|
self._re_central_gravity.setDecimals(2)
|
|
|
|
|
self._re_spring_length = QSpinBox()
|
|
|
|
|
self._re_spring_length.setRange(1, 1000)
|
|
|
|
|
self._re_spring_constant = QDoubleSpinBox()
|
|
|
|
|
self._re_spring_constant.setRange(0.0, 1.0)
|
|
|
|
|
self._re_spring_constant.setSingleStep(0.01)
|
|
|
|
|
self._re_spring_constant.setDecimals(3)
|
|
|
|
|
self._re_damping = QDoubleSpinBox()
|
|
|
|
|
self._re_damping.setRange(0.0, 1.0)
|
|
|
|
|
self._re_damping.setSingleStep(0.01)
|
|
|
|
|
self._re_damping.setDecimals(2)
|
|
|
|
|
form.addRow("Knotenabstand:", self._re_node_distance)
|
|
|
|
|
form.addRow("Zentrale Gravitation:", self._re_central_gravity)
|
|
|
|
|
form.addRow("Federlänge:", self._re_spring_length)
|
|
|
|
|
form.addRow("Federkonstante:", self._re_spring_constant)
|
|
|
|
|
form.addRow("Dämpfung:", self._re_damping)
|
|
|
|
|
return page
|
|
|
|
|
|
|
|
|
|
def _build_hierarchical_page(self) -> QWidget:
|
|
|
|
|
"""Erstellt die Parameter-Seite für das hierarchische Layout."""
|
|
|
|
|
page = QWidget()
|
|
|
|
|
form = QFormLayout(page)
|
|
|
|
|
self._hi_direction = QComboBox()
|
|
|
|
|
self._hi_direction.addItem("Oben → Unten", HierarchicalDirection.UD)
|
|
|
|
|
self._hi_direction.addItem("Unten → Oben", HierarchicalDirection.DU)
|
|
|
|
|
self._hi_direction.addItem("Links → Rechts", HierarchicalDirection.LR)
|
|
|
|
|
self._hi_direction.addItem("Rechts → Links", HierarchicalDirection.RL)
|
|
|
|
|
self._hi_sort_method = QComboBox()
|
|
|
|
|
self._hi_sort_method.addItem("Hubsize", HierarchicalSortMethod.HUBSIZE)
|
|
|
|
|
self._hi_sort_method.addItem("Gerichtet", HierarchicalSortMethod.DIRECTED)
|
|
|
|
|
self._hi_level_separation = QSpinBox()
|
|
|
|
|
self._hi_level_separation.setRange(1, 1000)
|
|
|
|
|
self._hi_node_spacing = QSpinBox()
|
|
|
|
|
self._hi_node_spacing.setRange(1, 1000)
|
|
|
|
|
self._hi_tree_spacing = QSpinBox()
|
|
|
|
|
self._hi_tree_spacing.setRange(1, 1000)
|
|
|
|
|
form.addRow("Richtung:", self._hi_direction)
|
|
|
|
|
form.addRow("Sortierung:", self._hi_sort_method)
|
|
|
|
|
form.addRow("Ebenenabstand:", self._hi_level_separation)
|
|
|
|
|
form.addRow("Knotenabstand:", self._hi_node_spacing)
|
|
|
|
|
form.addRow("Baumabstand:", self._hi_tree_spacing)
|
|
|
|
|
return page
|
|
|
|
|
|
|
|
|
|
def _on_layout_changed(self, index: int):
|
|
|
|
|
"""Wechselt die sichtbare Parameter-Seite und wendet das Layout sofort an."""
|
|
|
|
|
self._params_stack.setCurrentIndex(index)
|
|
|
|
|
self._apply_layout_to_graph()
|
|
|
|
|
|
|
|
|
|
def _current_layout_settings(self) -> GraphLayoutSettings:
|
|
|
|
|
"""Liest alle aktuellen Steuerelement-Werte aus und gibt ein GraphLayoutSettings-Objekt zurück."""
|
|
|
|
|
return GraphLayoutSettings(
|
|
|
|
|
layout=self._layout_combo.currentData(),
|
|
|
|
|
bh_gravitational_constant=self._bh_gravitational.value(),
|
|
|
|
|
bh_central_gravity=self._bh_central_gravity.value(),
|
|
|
|
|
bh_spring_length=self._bh_spring_length.value(),
|
|
|
|
|
bh_spring_constant=self._bh_spring_constant.value(),
|
|
|
|
|
bh_damping=self._bh_damping.value(),
|
|
|
|
|
fa_gravitational_constant=self._fa_gravitational.value(),
|
|
|
|
|
fa_central_gravity=self._fa_central_gravity.value(),
|
|
|
|
|
fa_spring_length=self._fa_spring_length.value(),
|
|
|
|
|
fa_spring_constant=self._fa_spring_constant.value(),
|
|
|
|
|
fa_damping=self._fa_damping.value(),
|
|
|
|
|
re_node_distance=self._re_node_distance.value(),
|
|
|
|
|
re_central_gravity=self._re_central_gravity.value(),
|
|
|
|
|
re_spring_length=self._re_spring_length.value(),
|
|
|
|
|
re_spring_constant=self._re_spring_constant.value(),
|
|
|
|
|
re_damping=self._re_damping.value(),
|
|
|
|
|
hi_direction=self._hi_direction.currentData(),
|
|
|
|
|
hi_sort_method=self._hi_sort_method.currentData(),
|
|
|
|
|
hi_level_separation=self._hi_level_separation.value(),
|
|
|
|
|
hi_node_spacing=self._hi_node_spacing.value(),
|
|
|
|
|
hi_tree_spacing=self._hi_tree_spacing.value(),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def _restore_graph_layout_settings(self):
|
|
|
|
|
"""Stellt die gespeicherten Layout-Einstellungen aus app_settings wieder her."""
|
|
|
|
|
s = app_settings.graph_layout_settings
|
|
|
|
|
|
|
|
|
|
# ComboBox: Layout auswählen (Signale blockieren, um Mehrfach-Updates zu vermeiden)
|
|
|
|
|
self._layout_combo.blockSignals(True)
|
|
|
|
|
for i in range(self._layout_combo.count()):
|
|
|
|
|
if self._layout_combo.itemData(i) == s.layout:
|
|
|
|
|
self._layout_combo.setCurrentIndex(i)
|
|
|
|
|
self._params_stack.setCurrentIndex(i)
|
|
|
|
|
break
|
|
|
|
|
self._layout_combo.blockSignals(False)
|
|
|
|
|
|
|
|
|
|
# barnesHut
|
|
|
|
|
self._bh_gravitational.setValue(s.bh_gravitational_constant)
|
|
|
|
|
self._bh_central_gravity.setValue(s.bh_central_gravity)
|
|
|
|
|
self._bh_spring_length.setValue(s.bh_spring_length)
|
|
|
|
|
self._bh_spring_constant.setValue(s.bh_spring_constant)
|
|
|
|
|
self._bh_damping.setValue(s.bh_damping)
|
|
|
|
|
|
|
|
|
|
# forceAtlas2Based
|
|
|
|
|
self._fa_gravitational.setValue(s.fa_gravitational_constant)
|
|
|
|
|
self._fa_central_gravity.setValue(s.fa_central_gravity)
|
|
|
|
|
self._fa_spring_length.setValue(s.fa_spring_length)
|
|
|
|
|
self._fa_spring_constant.setValue(s.fa_spring_constant)
|
|
|
|
|
self._fa_damping.setValue(s.fa_damping)
|
|
|
|
|
|
|
|
|
|
# repulsion
|
|
|
|
|
self._re_node_distance.setValue(s.re_node_distance)
|
|
|
|
|
self._re_central_gravity.setValue(s.re_central_gravity)
|
|
|
|
|
self._re_spring_length.setValue(s.re_spring_length)
|
|
|
|
|
self._re_spring_constant.setValue(s.re_spring_constant)
|
|
|
|
|
self._re_damping.setValue(s.re_damping)
|
|
|
|
|
|
|
|
|
|
# hierarchical
|
|
|
|
|
for i in range(self._hi_direction.count()):
|
|
|
|
|
if self._hi_direction.itemData(i) == s.hi_direction:
|
|
|
|
|
self._hi_direction.setCurrentIndex(i)
|
|
|
|
|
break
|
|
|
|
|
for i in range(self._hi_sort_method.count()):
|
|
|
|
|
if self._hi_sort_method.itemData(i) == s.hi_sort_method:
|
|
|
|
|
self._hi_sort_method.setCurrentIndex(i)
|
|
|
|
|
break
|
|
|
|
|
self._hi_level_separation.setValue(s.hi_level_separation)
|
|
|
|
|
self._hi_node_spacing.setValue(s.hi_node_spacing)
|
|
|
|
|
self._hi_tree_spacing.setValue(s.hi_tree_spacing)
|
|
|
|
|
|
|
|
|
|
logger.debug("Graph-Layout-Einstellungen wiederhergestellt: %s", s.layout)
|
|
|
|
|
|
|
|
|
|
def _apply_layout_to_graph(self):
|
|
|
|
|
"""Überträgt die aktuellen Layout-Einstellungen per JavaScript an den vis.js Graph."""
|
|
|
|
|
if not self._graph_loaded or self._web_view is None:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
s = self._current_layout_settings()
|
|
|
|
|
self._web_view.page().runJavaScript(_build_set_options_js(s))
|
|
|
|
|
logger.debug("Layout angewendet: %s", s.layout)
|
|
|
|
|
|
|
|
|
|
def _save_graph_layout_settings(self):
|
|
|
|
|
"""Speichert die aktuellen Layout-Einstellungen in app_settings und persistiert sie."""
|
|
|
|
|
app_settings.graph_layout_settings = self._current_layout_settings()
|
|
|
|
|
app_settings.save()
|
|
|
|
|
logger.info("Graph-Layout-Einstellungen gespeichert: %s", app_settings.graph_layout_settings.layout)
|
|
|
|
|
|
2026-03-14 20:50:09 +01:00
|
|
|
# === Netzwerkgraph (vis.js) ===
|
|
|
|
|
|
|
|
|
|
def _on_tab_changed(self, index: int):
|
2026-03-16 21:15:16 +01:00
|
|
|
"""Lazy-Init des Netzwerkgraphs beim ersten Tab-Wechsel und Sidebar-Seite synchronisieren."""
|
|
|
|
|
# Sidebar-Seite synchronisieren
|
|
|
|
|
self.ui.settingsStack.setCurrentIndex(index)
|
|
|
|
|
|
2026-03-14 20:50:09 +01:00
|
|
|
if not HAS_WEBENGINE:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
# Tab 1 = Netzwerkgraph
|
|
|
|
|
if index == 1 and not self._graph_loaded:
|
|
|
|
|
self._setup_network_graph()
|
|
|
|
|
|
2026-03-16 21:15:16 +01:00
|
|
|
# Suchbegriff auf den neuen Tab anwenden
|
|
|
|
|
search_text = self.ui.searchEdit.text()
|
|
|
|
|
if search_text:
|
|
|
|
|
self._on_search_changed(search_text)
|
|
|
|
|
|
2026-03-14 20:50:09 +01:00
|
|
|
def _setup_network_graph(self):
|
|
|
|
|
"""Erstellt QWebEngineView und lädt den vis.js Netzwerkgraph."""
|
|
|
|
|
self._web_view = QWebEngineView(self.ui.graphContainer)
|
|
|
|
|
self.ui.graphContainerLayout.addWidget(self._web_view)
|
|
|
|
|
|
|
|
|
|
# Graph-Daten aufbauen (nur direkte Abhängigkeiten für Kanten)
|
|
|
|
|
nodes_json, edges_json = self._build_graph_data()
|
|
|
|
|
html = self._generate_html(nodes_json, edges_json)
|
|
|
|
|
|
|
|
|
|
# HTML in temporäre Datei schreiben und per URL laden
|
|
|
|
|
# (setHtml() hat Größenlimit und kann bei großen Inhalten einfrieren)
|
|
|
|
|
self._temp_html_file = tempfile.NamedTemporaryFile(mode="w", suffix=".html", encoding="utf-8", delete=False)
|
|
|
|
|
self._temp_html_file.write(html)
|
|
|
|
|
self._temp_html_file.close()
|
2026-03-21 19:32:29 +01:00
|
|
|
self._web_view.loadFinished.connect(self._on_graph_load_finished)
|
2026-03-22 18:46:45 +01:00
|
|
|
self._web_view.page().titleChanged.connect(self._on_graph_title_changed)
|
2026-03-14 20:50:09 +01:00
|
|
|
self._web_view.setUrl(QUrl.fromLocalFile(self._temp_html_file.name))
|
|
|
|
|
|
2026-03-21 19:32:29 +01:00
|
|
|
def _on_graph_load_finished(self, ok: bool):
|
|
|
|
|
"""Wird aufgerufen, wenn die vis.js-Seite vollständig geladen ist."""
|
|
|
|
|
if ok:
|
|
|
|
|
self._graph_loaded = True
|
|
|
|
|
logger.info("Netzwerkgraph vollständig geladen")
|
|
|
|
|
else:
|
|
|
|
|
logger.error("Netzwerkgraph konnte nicht geladen werden")
|
2026-03-14 20:50:09 +01:00
|
|
|
|
2026-03-22 18:46:45 +01:00
|
|
|
def _on_graph_title_changed(self, title: str):
|
|
|
|
|
"""Reagiert auf Doppelklick im Netzwerkgraph: Kopiert Knotenname ins Suchfeld."""
|
|
|
|
|
prefix = "nodeSearch:"
|
|
|
|
|
if title.startswith(prefix):
|
|
|
|
|
node_label = title[len(prefix):]
|
|
|
|
|
self.ui.searchEdit.setText(node_label)
|
|
|
|
|
|
2026-03-14 20:50:09 +01:00
|
|
|
def _build_graph_data(self) -> tuple[str, str]:
|
|
|
|
|
"""
|
|
|
|
|
Konvertiert den Abhängigkeitsgraph in vis.js-kompatible JSON-Strukturen.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
tuple[str, str]: (nodes_json, edges_json)
|
|
|
|
|
"""
|
|
|
|
|
# Direkten Graph verwenden (nicht transitiv)
|
|
|
|
|
direct_graph = self.dependency_graph.build_direct_graph(self.xsl_root_dir)
|
|
|
|
|
|
|
|
|
|
# Pfad → ID Mapping
|
|
|
|
|
all_paths = sorted(direct_graph.keys(), key=lambda p: self._rel_path(p).lower())
|
|
|
|
|
path_to_id: dict[Path, int] = {path: idx for idx, path in enumerate(all_paths)}
|
|
|
|
|
|
|
|
|
|
# Nodes
|
|
|
|
|
nodes = []
|
|
|
|
|
for path, node_id in path_to_id.items():
|
|
|
|
|
rel = self._rel_path(path)
|
|
|
|
|
label = path.name
|
|
|
|
|
direct_deps = len(direct_graph.get(path, set()))
|
|
|
|
|
reverse_count = len(self._reverse_map.get(path, set()))
|
|
|
|
|
title = f"<b>{rel}</b><br>Importiert: {direct_deps}<br>Importiert von: {reverse_count}"
|
|
|
|
|
# Knotengröße basierend auf Verbindungsanzahl
|
|
|
|
|
value = direct_deps + reverse_count
|
|
|
|
|
|
|
|
|
|
nodes.append(
|
|
|
|
|
{
|
|
|
|
|
"id": node_id,
|
|
|
|
|
"label": label,
|
|
|
|
|
"title": title,
|
|
|
|
|
"value": max(value, 1),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Edges (nur direkte Abhängigkeiten)
|
|
|
|
|
edges = []
|
|
|
|
|
for path, deps in direct_graph.items():
|
|
|
|
|
from_id = path_to_id.get(path)
|
|
|
|
|
if from_id is None:
|
|
|
|
|
continue
|
|
|
|
|
for dep in deps:
|
|
|
|
|
to_id = path_to_id.get(dep)
|
|
|
|
|
if to_id is not None:
|
|
|
|
|
edges.append({"from": from_id, "to": to_id})
|
|
|
|
|
|
|
|
|
|
return json.dumps(nodes, ensure_ascii=False), json.dumps(edges, ensure_ascii=False)
|
|
|
|
|
|
|
|
|
|
def _generate_html(self, nodes_json: str, edges_json: str) -> str:
|
|
|
|
|
"""
|
|
|
|
|
Generiert die HTML-Seite mit inline vis.js und Graph-Daten.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
nodes_json: vis.js Nodes als JSON-String
|
|
|
|
|
edges_json: vis.js Edges als JSON-String
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
str: Vollständige HTML-Seite
|
|
|
|
|
"""
|
|
|
|
|
# vis.js Bibliothek einlesen
|
|
|
|
|
vis_js_path = Path(__file__).parent.parent / "res" / "vis-network.min.js"
|
|
|
|
|
try:
|
|
|
|
|
vis_js_content = vis_js_path.read_text(encoding="utf-8")
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Konnte vis-network.min.js nicht laden: {e}")
|
|
|
|
|
return f"<html><body><h2>Fehler: vis-network.min.js nicht gefunden</h2><p>{e}</p></body></html>"
|
|
|
|
|
|
|
|
|
|
# Hintergrundfarbe aus Qt-Palette ableiten
|
|
|
|
|
palette = self.palette()
|
|
|
|
|
bg_color = palette.window().color()
|
|
|
|
|
text_color = palette.windowText().color()
|
|
|
|
|
bg_hex = bg_color.name()
|
|
|
|
|
text_hex = text_color.name()
|
|
|
|
|
|
|
|
|
|
return f"""<!DOCTYPE html>
|
|
|
|
|
<html>
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="utf-8">
|
|
|
|
|
<style>
|
|
|
|
|
html, body {{
|
|
|
|
|
margin: 0;
|
|
|
|
|
padding: 0;
|
|
|
|
|
width: 100%;
|
|
|
|
|
height: 100%;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
background-color: {bg_hex};
|
|
|
|
|
color: {text_hex};
|
|
|
|
|
font-family: sans-serif;
|
|
|
|
|
}}
|
|
|
|
|
#graph-container {{
|
|
|
|
|
width: 100%;
|
|
|
|
|
height: 100%;
|
|
|
|
|
}}
|
|
|
|
|
.vis-tooltip {{
|
|
|
|
|
background-color: {bg_hex} !important;
|
|
|
|
|
color: {text_hex} !important;
|
|
|
|
|
border: 1px solid #888 !important;
|
|
|
|
|
border-radius: 4px !important;
|
|
|
|
|
padding: 8px !important;
|
|
|
|
|
font-size: 13px !important;
|
|
|
|
|
box-shadow: 2px 2px 6px rgba(0,0,0,0.3) !important;
|
|
|
|
|
}}
|
|
|
|
|
</style>
|
|
|
|
|
<script type="text/javascript">
|
|
|
|
|
{vis_js_content}
|
|
|
|
|
</script>
|
|
|
|
|
</head>
|
|
|
|
|
<body>
|
|
|
|
|
<div id="graph-container"></div>
|
|
|
|
|
<script type="text/javascript">
|
|
|
|
|
var nodesData = {nodes_json};
|
|
|
|
|
var edgesData = {edges_json};
|
|
|
|
|
|
2026-03-21 19:54:38 +01:00
|
|
|
// Titles von String zu DOM-Elementen konvertieren (vis.js rendert nur DOM-Elemente als HTML)
|
|
|
|
|
nodesData.forEach(function(node) {{
|
|
|
|
|
if (node.title) {{
|
|
|
|
|
var el = document.createElement('div');
|
|
|
|
|
el.innerHTML = node.title;
|
|
|
|
|
node.title = el;
|
|
|
|
|
}}
|
|
|
|
|
}});
|
|
|
|
|
|
2026-03-14 20:50:09 +01:00
|
|
|
var nodes = new vis.DataSet(nodesData);
|
|
|
|
|
var edges = new vis.DataSet(edgesData);
|
|
|
|
|
|
|
|
|
|
var container = document.getElementById('graph-container');
|
|
|
|
|
var data = {{ nodes: nodes, edges: edges }};
|
|
|
|
|
|
|
|
|
|
var options = {{
|
|
|
|
|
nodes: {{
|
|
|
|
|
shape: 'dot',
|
|
|
|
|
scaling: {{
|
|
|
|
|
min: 8,
|
|
|
|
|
max: 28,
|
|
|
|
|
label: {{ enabled: true, min: 10, max: 16 }}
|
|
|
|
|
}},
|
|
|
|
|
font: {{
|
|
|
|
|
size: 12,
|
|
|
|
|
color: '{text_hex}',
|
|
|
|
|
strokeWidth: 2,
|
|
|
|
|
strokeColor: '{bg_hex}'
|
|
|
|
|
}},
|
|
|
|
|
color: {{
|
|
|
|
|
background: '#4a90d9',
|
|
|
|
|
border: '#2c5f9e',
|
|
|
|
|
highlight: {{ background: '#ff8c00', border: '#cc7000' }},
|
|
|
|
|
hover: {{ background: '#5da0e9', border: '#3a6fae' }}
|
|
|
|
|
}}
|
|
|
|
|
}},
|
|
|
|
|
edges: {{
|
|
|
|
|
arrows: {{ to: {{ enabled: true, scaleFactor: 0.5 }} }},
|
|
|
|
|
color: {{
|
|
|
|
|
color: '#888888',
|
|
|
|
|
highlight: '#ff8c00',
|
|
|
|
|
hover: '#aaaaaa',
|
|
|
|
|
opacity: 0.7
|
|
|
|
|
}},
|
|
|
|
|
smooth: {{ type: 'continuous' }}
|
|
|
|
|
}},
|
|
|
|
|
interaction: {{
|
|
|
|
|
hover: true,
|
|
|
|
|
tooltipDelay: 200,
|
|
|
|
|
navigationButtons: true,
|
|
|
|
|
keyboard: true
|
|
|
|
|
}}
|
|
|
|
|
}};
|
|
|
|
|
|
2026-03-21 19:32:29 +01:00
|
|
|
// Layout-Einstellungen VOR Netzwerk-Erstellung in options einmischen
|
|
|
|
|
Object.assign(options, {_build_physics_options_dict(app_settings.graph_layout_settings)});
|
|
|
|
|
|
2026-03-14 20:50:09 +01:00
|
|
|
var network = new vis.Network(container, data, options);
|
|
|
|
|
|
|
|
|
|
// Nachbar-Hervorhebung bei Klick
|
|
|
|
|
var allNodes = nodes.get();
|
|
|
|
|
var originalColors = {{}};
|
|
|
|
|
allNodes.forEach(function(node) {{
|
|
|
|
|
originalColors[node.id] = {{
|
|
|
|
|
color: node.color || options.nodes.color,
|
|
|
|
|
font: node.font || options.nodes.font
|
|
|
|
|
}};
|
|
|
|
|
}});
|
|
|
|
|
|
|
|
|
|
network.on("selectNode", function(params) {{
|
|
|
|
|
var selectedId = params.nodes[0];
|
|
|
|
|
var connectedNodes = network.getConnectedNodes(selectedId);
|
|
|
|
|
var connectedSet = new Set(connectedNodes);
|
|
|
|
|
connectedSet.add(selectedId);
|
|
|
|
|
|
|
|
|
|
var updates = [];
|
|
|
|
|
allNodes.forEach(function(node) {{
|
|
|
|
|
if (connectedSet.has(node.id)) {{
|
|
|
|
|
updates.push({{
|
|
|
|
|
id: node.id,
|
|
|
|
|
opacity: 1.0
|
|
|
|
|
}});
|
|
|
|
|
}} else {{
|
|
|
|
|
updates.push({{
|
|
|
|
|
id: node.id,
|
|
|
|
|
opacity: 0.15
|
|
|
|
|
}});
|
|
|
|
|
}}
|
|
|
|
|
}});
|
|
|
|
|
nodes.update(updates);
|
|
|
|
|
|
|
|
|
|
// Kanten dimmen
|
|
|
|
|
var allEdges = edges.get();
|
|
|
|
|
var connectedEdges = network.getConnectedEdges(selectedId);
|
|
|
|
|
var connectedEdgeSet = new Set(connectedEdges);
|
|
|
|
|
var edgeUpdates = [];
|
|
|
|
|
allEdges.forEach(function(edge) {{
|
|
|
|
|
if (connectedEdgeSet.has(edge.id)) {{
|
|
|
|
|
edgeUpdates.push({{ id: edge.id, color: {{ opacity: 1.0 }} }});
|
|
|
|
|
}} else {{
|
|
|
|
|
edgeUpdates.push({{ id: edge.id, color: {{ opacity: 0.08 }} }});
|
|
|
|
|
}}
|
|
|
|
|
}});
|
|
|
|
|
edges.update(edgeUpdates);
|
|
|
|
|
}});
|
|
|
|
|
|
2026-03-22 18:46:45 +01:00
|
|
|
network.on("doubleClick", function(params) {{
|
|
|
|
|
if (params.nodes.length > 0) {{
|
|
|
|
|
var nodeId = params.nodes[0];
|
|
|
|
|
var node = nodes.get(nodeId);
|
|
|
|
|
if (node) {{
|
|
|
|
|
document.title = "nodeSearch:" + node.label;
|
|
|
|
|
}}
|
|
|
|
|
}}
|
|
|
|
|
}});
|
|
|
|
|
|
2026-03-14 20:50:09 +01:00
|
|
|
network.on("deselectNode", function() {{
|
|
|
|
|
// Alle Knoten zurücksetzen
|
|
|
|
|
var updates = [];
|
|
|
|
|
allNodes.forEach(function(node) {{
|
|
|
|
|
updates.push({{
|
|
|
|
|
id: node.id,
|
|
|
|
|
opacity: 1.0
|
|
|
|
|
}});
|
|
|
|
|
}});
|
|
|
|
|
nodes.update(updates);
|
|
|
|
|
|
|
|
|
|
// Alle Kanten zurücksetzen
|
|
|
|
|
var allEdges = edges.get();
|
|
|
|
|
var edgeUpdates = [];
|
|
|
|
|
allEdges.forEach(function(edge) {{
|
|
|
|
|
edgeUpdates.push({{ id: edge.id, color: {{ opacity: 0.7 }} }});
|
|
|
|
|
}});
|
|
|
|
|
edges.update(edgeUpdates);
|
|
|
|
|
}});
|
2026-03-16 21:15:16 +01:00
|
|
|
|
|
|
|
|
// Suchfunktion: Knoten nach Label filtern
|
|
|
|
|
function filterNodes(searchTerm, hideUnrelated) {{
|
|
|
|
|
var term = searchTerm.toLowerCase();
|
|
|
|
|
|
|
|
|
|
if (hideUnrelated) {{
|
|
|
|
|
// Originalzustand wiederherstellen
|
|
|
|
|
nodes.clear();
|
|
|
|
|
edges.clear();
|
|
|
|
|
nodes.add(nodesData);
|
|
|
|
|
edges.add(edgesData);
|
|
|
|
|
allNodes = nodes.get();
|
|
|
|
|
|
|
|
|
|
if (!term) return;
|
|
|
|
|
|
|
|
|
|
// 1. Matching-Knoten finden
|
|
|
|
|
var matchedIds = new Set();
|
|
|
|
|
nodesData.forEach(function(node) {{
|
|
|
|
|
if (node.label.toLowerCase().indexOf(term) !== -1) {{
|
|
|
|
|
matchedIds.add(node.id);
|
|
|
|
|
}}
|
|
|
|
|
}});
|
|
|
|
|
|
|
|
|
|
// 2. Gerichtete Adjazenzlisten aufbauen
|
|
|
|
|
var fwd = {{}}; // from → to (Import-Richtung)
|
|
|
|
|
var bwd = {{}}; // to → from (Reverse)
|
|
|
|
|
edgesData.forEach(function(edge) {{
|
|
|
|
|
if (!fwd[edge.from]) fwd[edge.from] = [];
|
|
|
|
|
if (!bwd[edge.to]) bwd[edge.to] = [];
|
|
|
|
|
fwd[edge.from].push(edge.to);
|
|
|
|
|
bwd[edge.to].push(edge.from);
|
|
|
|
|
}});
|
|
|
|
|
|
|
|
|
|
// 3. BFS vorwärts (Nachkommen) + rückwärts (Vorfahren)
|
|
|
|
|
var reachable = new Set(matchedIds);
|
|
|
|
|
var queue = Array.from(matchedIds);
|
|
|
|
|
while (queue.length > 0) {{
|
|
|
|
|
var current = queue.shift();
|
|
|
|
|
(fwd[current] || []).forEach(function(n) {{
|
|
|
|
|
if (!reachable.has(n)) {{ reachable.add(n); queue.push(n); }}
|
|
|
|
|
}});
|
|
|
|
|
}}
|
|
|
|
|
queue = Array.from(matchedIds);
|
|
|
|
|
while (queue.length > 0) {{
|
|
|
|
|
var current = queue.shift();
|
|
|
|
|
(bwd[current] || []).forEach(function(n) {{
|
|
|
|
|
if (!reachable.has(n)) {{ reachable.add(n); queue.push(n); }}
|
|
|
|
|
}});
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
|
|
// 4. Nicht-erreichbare Knoten und Kanten entfernen
|
|
|
|
|
var removeNodeIds = [];
|
|
|
|
|
nodesData.forEach(function(node) {{
|
|
|
|
|
if (!reachable.has(node.id)) removeNodeIds.push(node.id);
|
|
|
|
|
}});
|
|
|
|
|
nodes.remove(removeNodeIds);
|
|
|
|
|
|
|
|
|
|
var removeEdgeIds = [];
|
|
|
|
|
edgesData.forEach(function(edge) {{
|
|
|
|
|
if (!reachable.has(edge.from) || !reachable.has(edge.to))
|
|
|
|
|
removeEdgeIds.push(edge.id);
|
|
|
|
|
}});
|
|
|
|
|
edges.remove(removeEdgeIds);
|
|
|
|
|
|
|
|
|
|
// Matching-Knoten hervorheben, verbundene leicht dimmen
|
|
|
|
|
var updates = [];
|
|
|
|
|
matchedIds.forEach(function(id) {{
|
|
|
|
|
updates.push({{ id: id, opacity: 1.0 }});
|
|
|
|
|
}});
|
|
|
|
|
reachable.forEach(function(id) {{
|
|
|
|
|
if (!matchedIds.has(id)) {{
|
|
|
|
|
updates.push({{ id: id, opacity: 0.5 }});
|
|
|
|
|
}}
|
|
|
|
|
}});
|
|
|
|
|
nodes.update(updates);
|
|
|
|
|
allNodes = nodes.get();
|
|
|
|
|
|
|
|
|
|
}} else {{
|
|
|
|
|
// Originalzustand wiederherstellen falls vorher gefiltert
|
|
|
|
|
nodes.clear();
|
|
|
|
|
edges.clear();
|
|
|
|
|
nodes.add(nodesData);
|
|
|
|
|
edges.add(edgesData);
|
|
|
|
|
allNodes = nodes.get();
|
|
|
|
|
|
|
|
|
|
// Bisheriges Verhalten: nur dimmen
|
|
|
|
|
var updates = [];
|
|
|
|
|
var matchedIds = new Set();
|
|
|
|
|
|
|
|
|
|
allNodes.forEach(function(node) {{
|
|
|
|
|
if (!term || node.label.toLowerCase().indexOf(term) !== -1) {{
|
|
|
|
|
updates.push({{ id: node.id, opacity: 1.0 }});
|
|
|
|
|
matchedIds.add(node.id);
|
|
|
|
|
}} else {{
|
|
|
|
|
updates.push({{ id: node.id, opacity: 0.12 }});
|
|
|
|
|
}}
|
|
|
|
|
}});
|
|
|
|
|
nodes.update(updates);
|
|
|
|
|
|
|
|
|
|
var allEdges = edges.get();
|
|
|
|
|
var edgeUpdates = [];
|
|
|
|
|
allEdges.forEach(function(edge) {{
|
|
|
|
|
if (!term || (matchedIds.has(edge.from) && matchedIds.has(edge.to))) {{
|
|
|
|
|
edgeUpdates.push({{ id: edge.id, color: {{ opacity: 0.7 }} }});
|
|
|
|
|
}} else {{
|
|
|
|
|
edgeUpdates.push({{ id: edge.id, color: {{ opacity: 0.05 }} }});
|
|
|
|
|
}}
|
|
|
|
|
}});
|
|
|
|
|
edges.update(edgeUpdates);
|
|
|
|
|
}}
|
|
|
|
|
}}
|
2026-03-14 20:50:09 +01:00
|
|
|
</script>
|
|
|
|
|
</body>
|
|
|
|
|
</html>"""
|