diff --git a/app/frontend/ui_graph_cytoscape.py b/app/frontend/ui_graph_cytoscape.py index eb5fbd3..d79b4d2 100644 --- a/app/frontend/ui_graph_cytoscape.py +++ b/app/frontend/ui_graph_cytoscape.py @@ -1,6 +1,6 @@ import streamlit as st -# KORREKTER IMPORT für 'pip install streamlit-cytoscapejs' -from streamlit_cytoscapejs import st_cytoscapejs +# WICHTIG: Wir nutzen jetzt 'st-cytoscape' (pip install st-cytoscape) +from st_cytoscape import cytoscape from qdrant_client import models from ui_config import COLLECTION_PREFIX, GRAPH_COLORS from ui_callbacks import switch_to_editor_callback @@ -11,9 +11,9 @@ def render_graph_explorer_cytoscape(graph_service): if "graph_center_id" not in st.session_state: st.session_state.graph_center_id = None - # Layout Defaults für COSE - st.session_state.setdefault("cy_node_repulsion", 1000000) - st.session_state.setdefault("cy_ideal_edge_len", 150) + # 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 col_ctrl, col_graph = st.columns([1, 4]) @@ -37,9 +37,9 @@ def render_graph_explorer_cytoscape(graph_service): st.divider() with st.expander("👁️ Layout Einstellungen", expanded=True): - st.caption("COSE Layout") - st.session_state.cy_ideal_edge_len = st.slider("Kantenlänge", 50, 500, st.session_state.cy_ideal_edge_len) - st.session_state.cy_node_repulsion = st.slider("Knoten-Abstoßung", 100000, 2000000, st.session_state.cy_node_repulsion, step=100000) + 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) if st.button("Neu berechnen", key="cy_rerun"): st.rerun() @@ -58,8 +58,10 @@ def render_graph_explorer_cytoscape(graph_service): # 1. Daten laden with st.spinner("Lade Graph..."): - # Wir nutzen die bestehende Logik aus dem Service + # Wir holen die Agraph-Objekte vom Service nodes_data, edges_data = graph_service.get_ego_graph(center_id) + + # Note Data für Editor Button note_data = graph_service.get_note_with_full_content(center_id) # 2. Action Bar @@ -70,38 +72,49 @@ def render_graph_explorer_cytoscape(graph_service): if note_data: st.button("📝 Bearbeiten", use_container_width=True, on_click=switch_to_editor_callback, args=(note_data,), key="cy_edit") - # 3. Konvertierung zu Cytoscape JSON + # 3. Konvertierung zu Cytoscape JSON Format cy_elements = [] + # Nodes konvertieren for n in nodes_data: - # Node + # 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] + "..." + cy_node = { "data": { "id": n.id, - "label": n.label, - "bg_color": n.color, # Wichtig: Eigenes Attribut für Style mapping - "size": 40 if n.id == center_id else 25, + "label": label_display, + "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 }, - "selected": (n.id == center_id) + # Zentrum markieren + "selected": (n.id == center_id) } cy_elements.append(cy_node) + # Edges konvertieren for e in edges_data: - # Edge Fix: e.to statt e.target nutzen! + # FIX: Agraph Edges nutzen .to, nicht .target + # Wir prüfen sicherheitshalber beide Attribute target_id = getattr(e, "to", getattr(e, "target", None)) - cy_edge = { - "data": { - "source": e.source, - "target": target_id, # KORRIGIERT - "label": e.label, - "line_color": e.color + if target_id: + cy_edge = { + "data": { + "source": e.source, + "target": target_id, + "label": e.label, + "color": e.color + } } - } - cy_elements.append(cy_edge) + cy_elements.append(cy_edge) - # 4. Stylesheet + # 4. Stylesheet (Design Definitionen) stylesheet = [ { "selector": "node", @@ -109,82 +122,86 @@ def render_graph_explorer_cytoscape(graph_service): "label": "data(label)", "width": "data(size)", "height": "data(size)", - "background-color": "data(bg_color)", # Mapping auf unser data feld + "background-color": "data(color)", "color": "#333", - "font-size": "12px", + "font-size": "14px", "text-valign": "center", "text-halign": "center", "border-width": 2, - "border-color": "#fff" + "border-color": "#fff", + "text-wrap": "wrap", + "text-max-width": "100px" } }, { "selector": "node:selected", "style": { - "border-width": 4, - "border-color": "#FF5733" + "border-width": 5, + "border-color": "#FF5733", + "background-color": "#FF5733", + "color": "#fff" } }, { "selector": "edge", "style": { - "width": 2, - "line-color": "data(line_color)", - "target-arrow-color": "data(line_color)", + "width": 3, + "line-color": "data(color)", + "target-arrow-color": "data(color)", "target-arrow-shape": "triangle", "curve-style": "bezier", "label": "data(label)", - "font-size": "10px", - "color": "#777", - "text-background-opacity": 1, - "text-background-color": "#fff" + "font-size": "11px", + "color": "#555", + "text-background-opacity": 0.8, + "text-background-color": "#ffffff", + "text-rotation": "autorotate" } } ] - # 5. Rendering - # Die Bibliothek gibt eine Liste von geklickten Elementen zurück - clicked_elements = st_cytoscapejs( - elements=cy_elements, - stylesheet=stylesheet, - layout={ - "name": "cose", - "idealEdgeLength": st.session_state.cy_ideal_edge_len, - "nodeOverlap": 20, - "refresh": 20, - "fit": True, - "padding": 30, - "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 - }, - key="cyto_graph_obj", - height="800px" - ) + # 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" + ) # Interaktion: Klick Event verarbeiten - if clicked_elements: - # clicked_elements ist eine Liste von Dictionaries - # Wir suchen nach einem Node-Klick - for el in clicked_elements: - # Prüfen ob es ein Node ist (hat 'id' aber keine 'source'/'target' im root, - # allerdings liefert die Lib oft die rohen Daten) - # Wir schauen auf die ID. - clicked_id = el.get("data", {}).get("id") if "data" in el else el.get("id") + # st-cytoscape gibt ein Dictionary zurück + if selected_element: + # Prüfen, ob es ein Node-Klick war + # Die Struktur ist: {'nodes': ['id1', 'id2'], 'edges': [...]} + clicked_nodes = selected_element.get("nodes", []) + + if clicked_nodes: + # Wir nehmen den ersten (und meist einzigen) selektierten Node + clicked_id = clicked_nodes[0] if clicked_id and clicked_id != center_id: - # Safety check: ist es eine edge? Edges haben source/target - is_edge = "source" in el.get("data", {}) - if not is_edge: - st.session_state.graph_center_id = clicked_id - st.rerun() + st.session_state.graph_center_id = clicked_id + st.rerun() else: st.info("👈 Bitte wähle links eine Notiz aus.") \ No newline at end of file