Feat: vis.js Layout-Switcher im XSL-Abhängigkeitsgraph (v1.2.0)
Layout-Umschaltung zwischen barnesHut, ForceAtlas2, Repulsion und hierarchischem Layout mit konfigurierbaren Parametern pro Layout. Einstellungen werden persistent in AppSettings gespeichert und beim Öffnen des Dialogs wiederhergestellt. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+381
-24
@@ -13,13 +13,103 @@ from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QUrl, Qt
|
||||
from PySide6.QtGui import QIcon
|
||||
from PySide6.QtWidgets import QDialog, QTreeWidgetItem
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QDoubleSpinBox,
|
||||
QFormLayout,
|
||||
QLabel,
|
||||
QSpinBox,
|
||||
QStackedWidget,
|
||||
QTreeWidgetItem,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from conf import (
|
||||
GraphLayout,
|
||||
GraphLayoutSettings,
|
||||
HierarchicalDirection,
|
||||
HierarchicalSortMethod,
|
||||
app_settings,
|
||||
)
|
||||
from ui.XslDependencyDialog_ui import Ui_XslDependencyDialog
|
||||
from xsl_dependencies import XslDependencyGraph
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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)});"
|
||||
|
||||
|
||||
# Prüfe ob QWebEngineView verfügbar ist
|
||||
try:
|
||||
from PySide6.QtWebEngineWidgets import QWebEngineView
|
||||
@@ -80,12 +170,15 @@ class XslDependencyDialog(QDialog):
|
||||
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)
|
||||
self.ui.graphFilterConnectedCheck.toggled.connect(
|
||||
lambda: self._on_search_changed(self.ui.searchEdit.text())
|
||||
)
|
||||
self.ui.graphFilterConnectedCheck.toggled.connect(lambda: self._on_search_changed(self.ui.searchEdit.text()))
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Räumt temporäre Dateien und QWebEngineView auf."""
|
||||
# Layout-Controls in graphSettingsPage einfügen und Einstellungen wiederherstellen
|
||||
self._build_layout_controls()
|
||||
self._restore_graph_layout_settings()
|
||||
|
||||
def reject(self):
|
||||
"""Speichert Layout-Einstellungen und räumt temporäre Dateien und QWebEngineView auf."""
|
||||
self._save_graph_layout_settings()
|
||||
if self._web_view is not None:
|
||||
self._web_view.setUrl(QUrl("about:blank"))
|
||||
self._web_view.deleteLater()
|
||||
@@ -96,7 +189,7 @@ class XslDependencyDialog(QDialog):
|
||||
except Exception:
|
||||
pass
|
||||
self._temp_html_file = None
|
||||
super().closeEvent(event)
|
||||
super().reject()
|
||||
|
||||
def _build_reverse_map(self) -> dict[Path, set[Path]]:
|
||||
"""Baut eine Reverse-Map auf: Welche Dateien importieren eine gegebene Datei?"""
|
||||
@@ -241,6 +334,276 @@ class XslDependencyDialog(QDialog):
|
||||
self.ui.mainSplitter.setSizes([1000, 0])
|
||||
self.ui.sidebarWidget.setVisible(False)
|
||||
|
||||
# === 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)
|
||||
|
||||
# === Netzwerkgraph (vis.js) ===
|
||||
|
||||
def _on_tab_changed(self, index: int):
|
||||
@@ -274,10 +637,16 @@ class XslDependencyDialog(QDialog):
|
||||
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()
|
||||
self._web_view.loadFinished.connect(self._on_graph_load_finished)
|
||||
self._web_view.setUrl(QUrl.fromLocalFile(self._temp_html_file.name))
|
||||
self._graph_loaded = True
|
||||
|
||||
logger.info("Netzwerkgraph geladen")
|
||||
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")
|
||||
|
||||
def _build_graph_data(self) -> tuple[str, str]:
|
||||
"""
|
||||
@@ -428,21 +797,6 @@ class XslDependencyDialog(QDialog):
|
||||
}},
|
||||
smooth: {{ type: 'continuous' }}
|
||||
}},
|
||||
physics: {{
|
||||
solver: 'barnesHut',
|
||||
barnesHut: {{
|
||||
gravitationalConstant: -3000,
|
||||
centralGravity: 0.3,
|
||||
springLength: 150,
|
||||
springConstant: 0.04,
|
||||
damping: 0.09
|
||||
}},
|
||||
stabilization: {{
|
||||
enabled: true,
|
||||
iterations: 200,
|
||||
updateInterval: 25
|
||||
}}
|
||||
}},
|
||||
interaction: {{
|
||||
hover: true,
|
||||
tooltipDelay: 200,
|
||||
@@ -451,6 +805,9 @@ class XslDependencyDialog(QDialog):
|
||||
}}
|
||||
}};
|
||||
|
||||
// Layout-Einstellungen VOR Netzwerk-Erstellung in options einmischen
|
||||
Object.assign(options, {_build_physics_options_dict(app_settings.graph_layout_settings)});
|
||||
|
||||
var network = new vis.Network(container, data, options);
|
||||
|
||||
// Nachbar-Hervorhebung bei Klick
|
||||
|
||||
Reference in New Issue
Block a user