""" 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 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 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, project_xsl_paths: set[Path] | None = None, ): """ Args: parent: Eltern-Widget xsl_root_dir: Wurzelverzeichnis der XSL-Dateien dependency_graph: XSL-Abhängigkeitsgraph-Instanz project_xsl_paths: Absolute Pfade aller im Projekt referenzierten XSL-Dateien (None = kein Projekt) """ super().__init__(parent) self.setWindowFlags(self.windowFlags() | Qt.WindowMinMaxButtonsHint) self.xsl_root_dir = xsl_root_dir self.dependency_graph = dependency_graph self._project_xsl_paths: set[Path] = project_xsl_paths if project_xsl_paths is not None else set() self._web_view: "QWebEngineView | None" = None self._graph_loaded = False self._temp_html_file: tempfile.NamedTemporaryFile | None = None self._sidebar_visible = False self.ui = Ui_XslDependencyDialog() self.ui.setupUi(self) # Sidebar initial ausblenden self.ui.sidebarWidget.setVisible(False) self.ui.mainSplitter.setSizes([1000, 0]) self.ui.settingsButton.clicked.connect(self._toggle_sidebar) # 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) 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() 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() 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 super().reject() 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): """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): """Filtert die Dateiliste nach dem Suchbegriff.""" search_lower = text.lower() for i in range(self.ui.fileTree.topLevelItemCount()): item = self.ui.fileTree.topLevelItem(i) matches = not search_lower or search_lower in item.text(0).lower() item.setHidden(not matches) 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) # === 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): """Lazy-Init des Netzwerkgraphs beim ersten Tab-Wechsel und Sidebar-Seite synchronisieren.""" # Sidebar-Seite synchronisieren self.ui.settingsStack.setCurrentIndex(index) if not HAS_WEBENGINE: return # Tab 1 = Netzwerkgraph if index == 1 and not self._graph_loaded: self._setup_network_graph() # Suchbegriff auf den neuen Tab anwenden search_text = self.ui.searchEdit.text() if search_text: self._on_search_changed(search_text) 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() self._web_view.loadFinished.connect(self._on_graph_load_finished) self._web_view.page().titleChanged.connect(self._on_graph_title_changed) self._web_view.setUrl(QUrl.fromLocalFile(self._temp_html_file.name)) 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 _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) def _build_graph_data(self) -> tuple[str, str]: """ Konvertiert den Abhängigkeitsgraph in vis.js-kompatible JSON-Strukturen. Knoten werden in drei Kategorien eingeteilt: - Kategorie 1 (blau): Nur im Verzeichnis, nicht im Projekt - Kategorie 2 (grün): Im Projekt und im Verzeichnis - Kategorie 3 (rot): Im Projekt referenziert, aber Datei fehlt 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) # Dateisystem-Pfade (Kategorie 1 und 2) fs_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(fs_paths)} # Kategorie 3: Im Projekt referenziert, aber nicht im Dateisystem ghost_paths = sorted( self._project_xsl_paths - set(direct_graph.keys()), key=lambda p: p.name.lower(), ) ghost_offset = len(fs_paths) for idx, path in enumerate(ghost_paths): path_to_id[path] = ghost_offset + idx # Farb-Definitionen pro Kategorie color_fs_only = {"background": "#4a90d9", "border": "#2c5f9e"} # Kat. 1: nur Verzeichnis color_in_project = {"background": "#4caf50", "border": "#2e7d32"} # Kat. 2: Projekt + Verzeichnis color_ghost = {"background": "#e74c3c", "border": "#c0392b"} # Kat. 3: Projekt, Datei fehlt nodes = [] # Kategorie 1 und 2: Dateien die im Verzeichnis existieren for path in fs_paths: node_id = path_to_id[path] 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())) value = max(direct_deps + reverse_count, 1) in_project = path in self._project_xsl_paths title = ( f"{rel}
" f"Importiert: {direct_deps}
" f"Importiert von: {reverse_count}
" f"Im Projekt: {'Ja' if in_project else 'Nein'}" ) node: dict = { "id": node_id, "label": label, "title": title, "value": value, "color": color_in_project if in_project else color_fs_only, "borderWidth": 3 if in_project else 1, } nodes.append(node) # Kategorie 3: Ghost-Knoten (im Projekt, aber Datei fehlt im Verzeichnis) for path in ghost_paths: rel = self._rel_path(path) title = f"{rel}
Datei nicht gefunden
Im Projekt: Ja" nodes.append( { "id": path_to_id[path], "label": path.name, "title": title, "value": 1, "color": color_ghost, "borderWidth": 2, "shapeProperties": {"borderDashes": [5, 5]}, } ) # Edges (nur zwischen Dateisystem-Knoten — Ghost-Knoten haben keine bekannten 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"

Fehler: vis-network.min.js nicht gefunden

{e}

" # 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"""
Nur im Verzeichnis
Im Projekt & Verzeichnis
Im Projekt, Datei fehlt
"""