From 0294414d26e49628c8849a9c2c4cbfcebaa5d442 Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 14 Dec 2025 15:31:52 +0100 Subject: [PATCH] emprove ui_graph_cytoscape --- app/frontend/ui_graph_cytoscape.py | 221 +++++++++++++++-------------- 1 file changed, 114 insertions(+), 107 deletions(-) diff --git a/app/frontend/ui_graph_cytoscape.py b/app/frontend/ui_graph_cytoscape.py index c5987fe..62f87f7 100644 --- a/app/frontend/ui_graph_cytoscape.py +++ b/app/frontend/ui_graph_cytoscape.py @@ -7,18 +7,22 @@ from ui_callbacks import switch_to_editor_callback def render_graph_explorer_cytoscape(graph_service): st.header("🕸️ Graph Explorer (Cytoscape)") - # State Init + # --- STATE INITIALISIERUNG --- if "graph_center_id" not in st.session_state: st.session_state.graph_center_id = None + + # Neu: Getrennter State für Inspektion vs. Navigation + if "graph_inspected_id" not in st.session_state: + st.session_state.graph_inspected_id = None - # Defaults für Cytoscape Session State + # Defaults für Layout st.session_state.setdefault("cy_node_repulsion", 1000000) st.session_state.setdefault("cy_ideal_edge_len", 150) st.session_state.setdefault("cy_depth", 2) col_ctrl, col_graph = st.columns([1, 4]) - # --- CONTROLS (Linke Spalte) --- + # --- LINKES PANEL: SUCHE & SETTINGS --- with col_ctrl: st.subheader("Fokus") search_term = st.text_input("Suche Notiz", placeholder="Titel eingeben...", key="cy_search") @@ -31,24 +35,23 @@ def render_graph_explorer_cytoscape(graph_service): ) options = {h.payload['title']: h.payload['note_id'] for h in hits} if options: + # Bei Suche setzen wir beides neu: Zentrum und Inspektion selected_title = st.selectbox("Ergebnisse:", list(options.keys()), key="cy_select") if st.button("Laden", use_container_width=True, key="cy_load"): - st.session_state.graph_center_id = options[selected_title] + new_id = options[selected_title] + st.session_state.graph_center_id = new_id + st.session_state.graph_inspected_id = new_id # Gleichzeitig inspizieren st.rerun() st.divider() - # EINSTELLUNGEN - with st.expander("👁️ Ansicht & Layout", expanded=True): - # 1. Tiefe (Tier) + with st.expander("👁️ Layout Einstellungen", expanded=True): st.session_state.cy_depth = st.slider("Tiefe (Tier)", 1, 3, st.session_state.cy_depth, key="cy_depth_slider") - 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"): + if st.button("Neu berechnen", key="cy_rerun"): st.rerun() st.divider() @@ -56,63 +59,88 @@ 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 (Rechte Spalte) --- + # --- RECHTES PANEL: GRAPH & INSPECTOR --- with col_graph: center_id = st.session_state.graph_center_id - if center_id: - action_container = st.container() + # Falls noch nichts ausgewählt, initialisiere mit Inspektion oder None + if not center_id and st.session_state.graph_inspected_id: + center_id = st.session_state.graph_inspected_id + st.session_state.graph_center_id = center_id - # 1. Daten laden (Mit Tiefe!) + if center_id: + # Sync: Wenn Inspection None ist, setze auf Center + if not st.session_state.graph_inspected_id: + st.session_state.graph_inspected_id = center_id + + inspected_id = st.session_state.graph_inspected_id + + # --- DATEN LADEN --- + # 1. Graph für das ZENTRUM laden with st.spinner(f"Lade Graph (Tiefe {st.session_state.cy_depth})..."): - # Agraph-Objekte vom Service holen nodes_data, edges_data = graph_service.get_ego_graph( center_id, depth=st.session_state.cy_depth ) - # Note Data für Editor & Inspector (Volltext) - note_data = graph_service.get_note_with_full_content(center_id) + # 2. Daten für den INSPIZIERTEN Knoten laden (für Editor/Inspector) + inspected_data = graph_service.get_note_with_full_content(inspected_id) - # 2. Action Bar & Inspector + # --- ACTION BAR (OBEN) --- + action_container = st.container() with action_container: - c1, c2 = st.columns([3, 1]) - 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_btn") + # Info Zeile + c1, c2, c3 = st.columns([2, 1, 1]) - # --- DATA INSPECTOR --- - with st.expander("🕵️ Data Inspector (Details)", expanded=False): - if note_data: + with c1: + st.info(f"**Ausgewählt:** {inspected_data.get('title', inspected_id) if inspected_data else inspected_id}") + + with c2: + # NAVIGATION: Nur anzeigen, wenn Inspiziert != Zentrum + if inspected_id != center_id: + if st.button("🎯 Als Zentrum setzen", use_container_width=True, key="cy_nav_btn"): + st.session_state.graph_center_id = inspected_id + st.rerun() + else: + st.caption("_(Ist aktuelles Zentrum)_") + + with c3: + # EDITIEREN: Immer für den INSPIZIERTEN Knoten + if inspected_data: + st.button("📝 Bearbeiten", + use_container_width=True, + on_click=switch_to_editor_callback, + args=(inspected_data,), + key="cy_edit_btn") + + # --- INSPECTOR --- + with st.expander("🕵️ Data Inspector (Details)", expanded=True): # Default offen für bessere UX + if inspected_data: col_i1, col_i2 = st.columns(2) with col_i1: - st.markdown(f"**Titel:** {note_data.get('title')}") - st.markdown(f"**Typ:** `{note_data.get('type')}`") + st.markdown(f"**ID:** `{inspected_data.get('note_id')}`") + st.markdown(f"**Typ:** `{inspected_data.get('type')}`") with col_i2: - st.markdown(f"**Tags:** {', '.join(note_data.get('tags', []))}") - path_check = "✅" if note_data.get('path') else "❌" - st.markdown(f"**Pfad:** {path_check}") + st.markdown(f"**Tags:** {', '.join(inspected_data.get('tags', []))}") + path_str = inspected_data.get('path') or inspected_data.get('file_path') or "N/A" + st.markdown(f"**Pfad:** `{path_str}`") st.divider() - # Vorschau des Inhalts - content_preview = note_data.get('fulltext', '')[:500] - st.text_area("Inhalt (Vorschau)", content_preview + "...", height=100, disabled=True) - - with st.expander("Raw JSON"): - st.json(note_data) + content_preview = inspected_data.get('fulltext', '')[:600] + st.text_area("Inhalt (Vorschau)", content_preview + "...", height=150, disabled=True) else: - st.warning("Keine Daten für diesen Knoten.") + st.warning("Keine Daten für Auswahl geladen.") - # 3. Daten Konvertierung (Agraph -> Cytoscape JSON) + # --- GRAPH RENDERING --- cy_elements = [] - # Nodes + # Nodes konvertieren for n in nodes_data: - # Hover-Text aus n.title (vom Service vorbereitet) - tooltip_text = n.title if n.title else n.label + # Styles berechnen + is_center = (n.id == center_id) + is_inspected = (n.id == inspected_id) - # Label kürzen für Anzeige im Kreis + tooltip_text = n.title if n.title else n.label display_label = n.label if len(display_label) > 15 and " " in display_label: display_label = display_label.replace(" ", "\n", 1) @@ -121,60 +149,59 @@ def render_graph_explorer_cytoscape(graph_service): "data": { "id": n.id, "label": display_label, - "full_label": n.label, - "bg_color": n.color, - "size": 50 if n.id == center_id else 30, - # Tooltip Datenfeld + "bg_color": n.color, "tooltip": tooltip_text }, - "selected": (n.id == center_id) + # Selektion markiert den INSPIZIERTEN Knoten, nicht zwingend das Zentrum + "selected": is_inspected, + "classes": "center" if is_center else "" } cy_elements.append(cy_node) - # Edges + # Edges konvertieren for e in edges_data: - # Kompatibilität Agraph -> Cytoscape target_id = getattr(e, "to", getattr(e, "target", None)) - if target_id: cy_edge = { "data": { - "source": e.source, - "target": target_id, - "label": e.label, - "line_color": e.color + "source": e.source, "target": target_id, "label": e.label, "line_color": e.color } } cy_elements.append(cy_edge) - # 4. Styling (CSS-like) + # Stylesheet definieren stylesheet = [ { "selector": "node", "style": { "label": "data(label)", - "width": "data(size)", - "height": "data(size)", + "width": "30px", "height": "30px", "background-color": "data(bg_color)", - "color": "#333", - "font-size": "12px", - "text-valign": "center", - "text-halign": "center", - "text-wrap": "wrap", - "text-max-width": "80px", - "border-width": 2, - "border-color": "#fff", - # Tooltip Mapping (Browser abhängig) + "color": "#333", "font-size": "12px", + "text-valign": "center", "text-halign": "center", + "text-wrap": "wrap", "text-max-width": "90px", + "border-width": 2, "border-color": "#fff", "title": "data(tooltip)" } }, + # Style für den inspizierten Knoten (Gelber Rahmen, Größer) { "selector": "node:selected", + "style": { + "border-width": 6, + "border-color": "#FFC300", # Gelb/Gold für Auswahl + "width": "50px", "height": "50px", + "font-weight": "bold", + "z-index": 999 + } + }, + # Style für das Zentrum (Roter Rahmen, falls nicht selektiert) + { + "selector": ".center", "style": { "border-width": 4, - "border-color": "#FF5733", - "background-color": "#FF5733", - "color": "#fff" + "border-color": "#FF5733", # Rot + "width": "40px", "height": "40px" } }, { @@ -186,61 +213,41 @@ def render_graph_explorer_cytoscape(graph_service): "target-arrow-shape": "triangle", "curve-style": "bezier", "label": "data(label)", - "font-size": "10px", - "color": "#777", - "text-background-opacity": 0.8, - "text-background-color": "#fff", - "text-rotation": "autorotate" + "font-size": "10px", "color": "#666", + "text-background-opacity": 0.8, "text-background-color": "#fff" } } ] - # 5. Rendering & Layout + # Render Graph graph_key = f"cy_{center_id}_{st.session_state.cy_depth}_{st.session_state.cy_ideal_edge_len}" - # Das Event-Dictionary enthält die geklickten Elemente clicked_elements = 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, + "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 + "edgeElasticity": 100, "nestingFactor": 5, "gravity": 80, + "numIter": 1000, "initialTemp": 200, "coolingFactor": 0.95, "minTemp": 1.0 }, key=graph_key, - height="800px" + height="700px" ) - # 6. Interaktions-Logik (Navigation Fix) + # --- EVENT HANDLING (Selektion) --- if clicked_elements: - # clicked_elements['nodes'] ist eine Liste von IDs - clicked_node_ids = clicked_elements.get("nodes", []) - - target_node = None - - # Wir suchen einen Node, der NICHT das aktuelle Zentrum ist - for nid in clicked_node_ids: - if nid != center_id: - target_node = nid - break - - # Wenn ein neuer Knoten gefunden wurde -> Navigieren - if target_node: - st.session_state.graph_center_id = target_node - st.rerun() + clicked_nodes = clicked_elements.get("nodes", []) + if clicked_nodes: + clicked_id = clicked_nodes[0] + + # LOGIK: Nur Inspektion ändern, nicht Zentrum + if clicked_id != st.session_state.graph_inspected_id: + st.session_state.graph_inspected_id = clicked_id + st.rerun() else: st.info("👈 Bitte wähle links eine Notiz aus.") \ No newline at end of file