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:
2026-03-21 19:32:29 +01:00
parent f7ef90079a
commit 4e65a6ad4c
7 changed files with 446 additions and 29 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
<!-- Paket-Definition (ersetzt Product in v4) -->
<Package
Name="DocuMentor"
Version="1.1.1"
Version="1.2.0"
Manufacturer="Vitali Graf / Software- und Datenbankentwicklung"
UpgradeCode="F498B66C-726D-44AA-95F4-CB4FBDCEF26E"
Language="1031"
+1 -1
View File
@@ -253,5 +253,5 @@ HINWEISE
================================================================================
Stand: März 2026
Erstellt für: DocuMentor v1.1.1
Erstellt für: DocuMentor v1.2.0
================================================================================
+1 -1
View File
@@ -10,7 +10,7 @@
; Build-Befehl: iscc installer.iss
#define MyAppName "DocuMentor"
#define MyAppVersion "1.1.1"
#define MyAppVersion "1.2.0"
#define MyAppPublisher "Ihr Name/Organisation"
#define MyAppURL "https://github.com/yourusername/xsl-validator"
#define MyAppExeName "DocuMentor.exe"
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "DocuMentor"
version = "1.1.1"
version = "1.2.0"
description = "Professionelle XSL-Transformations-Verwaltung und PDF-Generierung"
readme = "README.md"
license = {text = "MIT"}
+60
View File
@@ -80,6 +80,65 @@ class XsltVersion(str, Enum):
XSLT_2_0_3_0 = "2.0/3.0" # s9api (XSLT 2.0 und 3.0)
class GraphLayout(str, Enum):
"""vis.js Physics-Solver / Layout-Modus."""
BARNES_HUT = "barnesHut"
FORCE_ATLAS2 = "forceAtlas2Based"
REPULSION = "repulsion"
HIERARCHICAL = "hierarchical"
class HierarchicalDirection(str, Enum):
"""Richtung für hierarchisches Layout."""
UD = "UD"
DU = "DU"
LR = "LR"
RL = "RL"
class HierarchicalSortMethod(str, Enum):
"""Sortiermethode für hierarchisches Layout."""
HUBSIZE = "hubsize"
DIRECTED = "directed"
class GraphLayoutSettings(BaseModel):
"""Persistierte vis.js Layout-Einstellungen für den XSL-Abhängigkeitsgraph."""
layout: GraphLayout = GraphLayout.BARNES_HUT
# barnesHut
bh_gravitational_constant: int = -3000
bh_central_gravity: float = 0.3
bh_spring_length: int = 150
bh_spring_constant: float = 0.04
bh_damping: float = 0.09
# forceAtlas2Based
fa_gravitational_constant: int = -50
fa_central_gravity: float = 0.01
fa_spring_length: int = 100
fa_spring_constant: float = 0.08
fa_damping: float = 0.4
# repulsion
re_node_distance: int = 120
re_central_gravity: float = 0.0
re_spring_length: int = 200
re_spring_constant: float = 0.05
re_damping: float = 0.09
# hierarchical
hi_direction: HierarchicalDirection = HierarchicalDirection.UD
hi_sort_method: HierarchicalSortMethod = HierarchicalSortMethod.HUBSIZE
hi_level_separation: int = 150
hi_node_spacing: int = 100
hi_tree_spacing: int = 200
class PostgreSqlDb(BaseModel):
id: int
name: str
@@ -147,6 +206,7 @@ class AppSettings(BaseSettings):
window_geometry: tuple[int, int, int, int] | None = None # (x, y, width, height)
splitter_sizes: list[int] | None = None # Splitter-Positionen
tree_column_widths: list[int] | None = None # TreeWidget-Spaltenbreiten
graph_layout_settings: GraphLayoutSettings = Field(default_factory=GraphLayoutSettings)
model_config = SettingsConfigDict(json_file=config_path)
+381 -24
View File
@@ -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
Generated
+1 -1
View File
@@ -34,7 +34,7 @@ wheels = [
[[package]]
name = "documentor"
version = "1.1.0"
version = "1.2.0"
source = { virtual = "." }
dependencies = [
{ name = "connectorx" },