diff --git a/DocuMentor.wxs b/DocuMentor.wxs index 1a3785a..c358411 100644 --- a/DocuMentor.wxs +++ b/DocuMentor.wxs @@ -4,7 +4,7 @@ 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 diff --git a/uv.lock b/uv.lock index 0b392cc..e58cb1b 100644 --- a/uv.lock +++ b/uv.lock @@ -34,7 +34,7 @@ wheels = [ [[package]] name = "documentor" -version = "1.1.0" +version = "1.2.0" source = { virtual = "." } dependencies = [ { name = "connectorx" },