diff --git a/app/frontend/ui_graph_cytoscape.py b/app/frontend/ui_graph_cytoscape.py index d79b4d2..ca21b04 100644 --- a/app/frontend/ui_graph_cytoscape.py +++ b/app/frontend/ui_graph_cytoscape.py @@ -1,5 +1,5 @@ import streamlit as st -# WICHTIG: Wir nutzen jetzt 'st-cytoscape' (pip install st-cytoscape) +# Wir nutzen das mächtigere 'st-cytoscape' (pip install st-cytoscape) from st_cytoscape import cytoscape from qdrant_client import models from ui_config import COLLECTION_PREFIX, GRAPH_COLORS @@ -8,16 +8,18 @@ from ui_callbacks import switch_to_editor_callback def render_graph_explorer_cytoscape(graph_service): st.header("🕸️ Graph Explorer (Cytoscape)") + # State Init if "graph_center_id" not in st.session_state: st.session_state.graph_center_id = None - # Layout Defaults für COSE (Compound Spring Embedder) - st.session_state.setdefault("cy_node_repulsion", 1000000) # Starke Abstoßung - st.session_state.setdefault("cy_ideal_edge_len", 200) # Ziel-Abstand + # Defaults für Cytoscape Session State + st.session_state.setdefault("cy_node_repulsion", 1000000) + st.session_state.setdefault("cy_ideal_edge_len", 150) + st.session_state.setdefault("cy_depth", 2) # Eigene Tiefe für diesen Tab col_ctrl, col_graph = st.columns([1, 4]) - # --- CONTROLS --- + # --- CONTROLS (Linke Spalte) --- with col_ctrl: st.subheader("Fokus") search_term = st.text_input("Suche Notiz", placeholder="Titel eingeben...", key="cy_search") @@ -36,12 +38,18 @@ def render_graph_explorer_cytoscape(graph_service): st.rerun() st.divider() - with st.expander("👁️ Layout Einstellungen", expanded=True): - st.caption("COSE Layout (Optimiert für Abstände)") - st.session_state.cy_ideal_edge_len = st.slider("Kantenlänge (Ideal)", 50, 600, st.session_state.cy_ideal_edge_len) - st.session_state.cy_node_repulsion = st.slider("Knoten-Abstoßung", 100000, 5000000, st.session_state.cy_node_repulsion, step=100000) + + # EINSTELLUNGEN + with st.expander("👁️ Ansicht & Layout", expanded=True): + # 1. Tiefe (Tier) + st.session_state.cy_depth = st.slider("Tiefe (Tier)", 1, 3, st.session_state.cy_depth, key="cy_depth_slider") - if st.button("Neu berechnen", key="cy_rerun"): + st.markdown("**COSE Layout**") + # 2. Layout Parameter + st.session_state.cy_ideal_edge_len = st.slider("Kantenlänge", 50, 600, st.session_state.cy_ideal_edge_len, key="cy_len_slider") + st.session_state.cy_node_repulsion = st.slider("Knoten-Abstoßung", 100000, 5000000, st.session_state.cy_node_repulsion, step=100000, key="cy_rep_slider") + + if st.button("Neu berechnen / Reset", key="cy_rerun"): st.rerun() st.divider() @@ -49,19 +57,23 @@ def render_graph_explorer_cytoscape(graph_service): for k, v in list(GRAPH_COLORS.items())[:8]: st.markdown(f" {k}", unsafe_allow_html=True) - # --- GRAPH AREA --- + # --- GRAPH AREA (Rechte Spalte) --- with col_graph: center_id = st.session_state.graph_center_id if center_id: action_container = st.container() - # 1. Daten laden - with st.spinner("Lade Graph..."): - # Wir holen die Agraph-Objekte vom Service - nodes_data, edges_data = graph_service.get_ego_graph(center_id) + # 1. Daten laden (Mit Tiefe!) + with st.spinner(f"Lade Graph (Tiefe {st.session_state.cy_depth})..."): + # Hier nutzen wir die dynamische Tiefe aus dem Slider + # Wir holen die Agraph-Objekte vom Service, da die Logik dort zentralisiert ist + nodes_data, edges_data = graph_service.get_ego_graph( + center_id, + depth=st.session_state.cy_depth + ) - # Note Data für Editor Button + # Note Data für Editor Button (Volltext/Pfad via Service) note_data = graph_service.get_note_with_full_content(center_id) # 2. Action Bar @@ -70,36 +82,38 @@ def render_graph_explorer_cytoscape(graph_service): with c1: st.caption(f"Zentrum: **{center_id}**") with c2: if note_data: - st.button("📝 Bearbeiten", use_container_width=True, on_click=switch_to_editor_callback, args=(note_data,), key="cy_edit") + st.button("📝 Bearbeiten", use_container_width=True, on_click=switch_to_editor_callback, args=(note_data,), key="cy_edit_btn") - # 3. Konvertierung zu Cytoscape JSON Format + # 3. Daten Konvertierung (Agraph -> Cytoscape JSON) cy_elements = [] - # Nodes konvertieren + # Nodes for n in nodes_data: - # Wir bauen das 'label' so um, dass lange Titel umbrechen (optional) - label_display = n.label - if len(label_display) > 20: label_display = label_display[:20] + "..." + # Wir holen den Hover-Text aus 'n.title', den der Service vorbereitet hat (Fulltext/Snippet) + tooltip_text = n.title if n.title else n.label + + # Label kürzen für Anzeige im Kreis, aber voll im Tooltip + display_label = n.label + if len(display_label) > 15 and " " in display_label: + display_label = display_label.replace(" ", "\n", 1) # Zeilenumbruch cy_node = { "data": { "id": n.id, - "label": label_display, + "label": display_label, "full_label": n.label, - "color": n.color, - # Größe skalieren: Center größer - "size": 60 if n.id == center_id else 40, - # Tooltip Inhalt - "tooltip": n.title + "bg_color": n.color, + "size": 50 if n.id == center_id else 30, + # Dieses Feld wird oft automatisch als natives 'title' Attribut gerendert (Browser Tooltip) + "title": tooltip_text }, - # Zentrum markieren - "selected": (n.id == center_id) + "selected": (n.id == center_id) } cy_elements.append(cy_node) - # Edges konvertieren + # Edges for e in edges_data: - # FIX: Agraph Edges nutzen .to, nicht .target + # Kompatibilität: Agraph nutzt 'to', Cytoscape braucht 'target' # Wir prüfen sicherheitshalber beide Attribute target_id = getattr(e, "to", getattr(e, "target", None)) @@ -109,12 +123,12 @@ def render_graph_explorer_cytoscape(graph_service): "source": e.source, "target": target_id, "label": e.label, - "color": e.color + "line_color": e.color } } cy_elements.append(cy_edge) - # 4. Stylesheet (Design Definitionen) + # 4. Styling (CSS-like) stylesheet = [ { "selector": "node", @@ -122,21 +136,23 @@ def render_graph_explorer_cytoscape(graph_service): "label": "data(label)", "width": "data(size)", "height": "data(size)", - "background-color": "data(color)", + "background-color": "data(bg_color)", "color": "#333", - "font-size": "14px", + "font-size": "12px", "text-valign": "center", "text-halign": "center", + "text-wrap": "wrap", + "text-max-width": "80px", "border-width": 2, "border-color": "#fff", - "text-wrap": "wrap", - "text-max-width": "100px" + # Tooltip Mapping (funktioniert je nach Browser/Wrapper) + "content": "data(label)" } }, { "selector": "node:selected", "style": { - "border-width": 5, + "border-width": 4, "border-color": "#FF5733", "background-color": "#FF5733", "color": "#fff" @@ -145,60 +161,59 @@ def render_graph_explorer_cytoscape(graph_service): { "selector": "edge", "style": { - "width": 3, - "line-color": "data(color)", - "target-arrow-color": "data(color)", + "width": 2, + "line-color": "data(line_color)", + "target-arrow-color": "data(line_color)", "target-arrow-shape": "triangle", "curve-style": "bezier", "label": "data(label)", - "font-size": "11px", - "color": "#555", - "text-background-opacity": 0.8, - "text-background-color": "#ffffff", + "font-size": "10px", + "color": "#666", + "text-background-opacity": 0.7, + "text-background-color": "#fff", "text-rotation": "autorotate" } } ] - # 5. Rendering mit COSE Layout - # COSE ist der Schlüssel für gute Abstände! - with st.spinner("Berechne Layout..."): - selected_element = cytoscape( - elements=cy_elements, - stylesheet=stylesheet, - layout={ - "name": "cose", - "idealEdgeLength": st.session_state.cy_ideal_edge_len, - "nodeOverlap": 20, - "refresh": 20, - "fit": True, - "padding": 50, - "randomize": False, - "componentSpacing": 200, - "nodeRepulsion": st.session_state.cy_node_repulsion, - "edgeElasticity": 100, - "nestingFactor": 5, - "gravity": 50, - "numIter": 1000, - "initialTemp": 200, - "coolingFactor": 0.95, - "minTemp": 1.0 - }, - key="cyto_graph_obj", # Ein fester Key ist hier okay, da das Layout-Dict sich ändert - height="700px" - ) + # 5. Rendering & Layout + # COSE Layout für optimale Abstände + selected_element = cytoscape( + elements=cy_elements, + stylesheet=stylesheet, + layout={ + "name": "cose", + "idealEdgeLength": st.session_state.cy_ideal_edge_len, + "nodeOverlap": 20, + "refresh": 20, + "fit": True, + "padding": 50, + "randomize": False, + "componentSpacing": 100, + "nodeRepulsion": st.session_state.cy_node_repulsion, + "edgeElasticity": 100, + "nestingFactor": 5, + "gravity": 80, + "numIter": 1000, + "initialTemp": 200, + "coolingFactor": 0.95, + "minTemp": 1.0 + }, + # Dynamischer Key für Re-Render bei Einstellungsänderung + key=f"cy_{center_id}_{st.session_state.cy_depth}_{st.session_state.cy_ideal_edge_len}", + height="800px" + ) - # Interaktion: Klick Event verarbeiten - # st-cytoscape gibt ein Dictionary zurück + # 6. Interaktions-Logik (Navigation) if selected_element: - # Prüfen, ob es ein Node-Klick war - # Die Struktur ist: {'nodes': ['id1', 'id2'], 'edges': [...]} + # st-cytoscape gibt ein Dictionary der selektierten Elemente zurück + # Struktur: {'nodes': ['id1'], 'edges': []} clicked_nodes = selected_element.get("nodes", []) if clicked_nodes: - # Wir nehmen den ersten (und meist einzigen) selektierten Node clicked_id = clicked_nodes[0] + # Wenn auf einen anderen Knoten geklickt wurde -> Navigieren if clicked_id and clicked_id != center_id: st.session_state.graph_center_id = clicked_id st.rerun()