From 9a55d45832024aabb53d0652a6f6602166a6cc02 Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 14 Dec 2025 16:29:08 +0100 Subject: [PATCH] update --- app/frontend/ui_graph_cytoscape.py | 158 +++++++++++++++++------------ 1 file changed, 95 insertions(+), 63 deletions(-) diff --git a/app/frontend/ui_graph_cytoscape.py b/app/frontend/ui_graph_cytoscape.py index c314ebf..38d0023 100644 --- a/app/frontend/ui_graph_cytoscape.py +++ b/app/frontend/ui_graph_cytoscape.py @@ -7,25 +7,31 @@ from ui_callbacks import switch_to_editor_callback def render_graph_explorer_cytoscape(graph_service): st.header("🕸️ Graph Explorer (Cytoscape)") - # --- STATE INITIALISIERUNG --- + # --- 1. STATE INITIALISIERUNG --- + # Graph Zentrum (Roter Rahmen, bestimmt die geladenen Daten) if "graph_center_id" not in st.session_state: st.session_state.graph_center_id = None + # Inspizierter Knoten (Gelber Rahmen, bestimmt Inspector & Editor) if "graph_inspected_id" not in st.session_state: st.session_state.graph_inspected_id = None - # Defaults für Layout + # Defaults für Layout-Parameter (COSE) st.session_state.setdefault("cy_node_repulsion", 1000000) st.session_state.setdefault("cy_ideal_edge_len", 150) st.session_state.setdefault("cy_depth", 2) + # Layout Spalten col_ctrl, col_graph = st.columns([1, 4]) - # --- LINKES PANEL: SUCHE & SETTINGS --- + # --- 2. LINKES PANEL (CONTROLS) --- with col_ctrl: st.subheader("Fokus") + + # Suchfeld search_term = st.text_input("Suche Notiz", placeholder="Titel eingeben...", key="cy_search") + # Suchlogik if search_term: hits, _ = graph_service.client.scroll( collection_name=f"{COLLECTION_PREFIX}_notes", @@ -33,18 +39,22 @@ def render_graph_explorer_cytoscape(graph_service): scroll_filter=models.Filter(must=[models.FieldCondition(key="title", match=models.MatchText(text=search_term))]) ) options = {h.payload['title']: h.payload['note_id'] for h in hits} + if options: selected_title = st.selectbox("Ergebnisse:", list(options.keys()), key="cy_select") if st.button("Laden", use_container_width=True, key="cy_load"): new_id = options[selected_title] + # Bei Suche setzen wir beides neu st.session_state.graph_center_id = new_id st.session_state.graph_inspected_id = new_id st.rerun() st.divider() + # Layout Einstellungen 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**") 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") @@ -53,15 +63,17 @@ def render_graph_explorer_cytoscape(graph_service): st.rerun() st.divider() + + # Legende st.caption("Legende") for k, v in list(GRAPH_COLORS.items())[:8]: st.markdown(f" {k}", unsafe_allow_html=True) - # --- RECHTES PANEL: GRAPH & INSPECTOR --- + # --- 3. RECHTES PANEL (GRAPH & INSPECTOR) --- with col_graph: center_id = st.session_state.graph_center_id - # Init Fallback + # Initialisierung Fallback 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 @@ -73,27 +85,29 @@ def render_graph_explorer_cytoscape(graph_service): inspected_id = st.session_state.graph_inspected_id - # --- DATEN LADEN --- + # --- 3.1 DATEN LADEN --- with st.spinner(f"Lade Graph (Tiefe {st.session_state.cy_depth})..."): - # 1. Graph Daten + # 1. Graph Daten für das ZENTRUM nodes_data, edges_data = graph_service.get_ego_graph( center_id, depth=st.session_state.cy_depth ) - # 2. Detail Daten (nur für den Inspector) + # 2. Detail Daten für die INSPEKTION (Editor/Text) inspected_data = graph_service.get_note_with_full_content(inspected_id) - # --- ACTION BAR --- + # --- 3.2 ACTION BAR & INSPECTOR --- action_container = st.container() with action_container: + # Obere Zeile: Info & Buttons c1, c2, c3 = st.columns([2, 1, 1]) with c1: title_show = inspected_data.get('title', inspected_id) if inspected_data else inspected_id - st.info(f"**Ausgewählt:** {title_show}") + st.info(f"**Aktuell gewählt:** {title_show}") with c2: - # NAVIGATION + # NAVIGATION BUTTON + # Nur anzeigen, wenn wir nicht schon im Zentrum sind 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 @@ -102,7 +116,7 @@ def render_graph_explorer_cytoscape(graph_service): st.caption("_(Ist aktuelles Zentrum)_") with c3: - # EDITIEREN + # EDIT BUTTON if inspected_data: st.button("📝 Bearbeiten", use_container_width=True, @@ -110,8 +124,8 @@ def render_graph_explorer_cytoscape(graph_service): args=(inspected_data,), key="cy_edit_btn") - # --- DATA INSPECTOR (Eingeklappt) --- - with st.expander("🕵️ Data Inspector", expanded=False): + # DATA INSPECTOR (Standard: Eingeklappt) + with st.expander("🕵️ Data Inspector (Details)", expanded=False): if inspected_data: col_i1, col_i2 = st.columns(2) with col_i1: @@ -121,20 +135,26 @@ def render_graph_explorer_cytoscape(graph_service): st.markdown(f"**Tags:** {', '.join(inspected_data.get('tags', []))}") path_check = "✅" if inspected_data.get('path') else "❌" st.markdown(f"**Pfad:** {path_check}") - st.text_area("Inhalt", inspected_data.get('fulltext', '')[:1000], height=200, disabled=True) + + st.text_area("Inhalt (Vorschau)", inspected_data.get('fulltext', '')[:1000], height=200, disabled=True) else: st.warning("Keine Daten geladen.") - # --- GRAPH ELEMENTS --- + # --- 3.3 ELEMENT VORBEREITUNG --- cy_elements = [] + # Nodes erstellen for n in nodes_data: - # Logik: Wir vergeben Klassen statt Selection-State + # Klassenlogik für Styling (statt Selection State) classes = [] - if n.id == center_id: classes.append("center") - if n.id == inspected_id: classes.append("inspected") + if n.id == center_id: + classes.append("center") - tooltip_text = n.title if n.title else n.label + # Wir markieren den inspizierten Knoten visuell + if n.id == inspected_id: + classes.append("inspected") + + # Label kürzen für Anzeige display_label = n.label if len(display_label) > 15 and " " in display_label: display_label = display_label.replace(" ", "\n", 1) @@ -144,13 +164,18 @@ def render_graph_explorer_cytoscape(graph_service): "id": n.id, "label": display_label, "bg_color": n.color, - "tooltip": tooltip_text + # Tooltip Inhalt + "tooltip": n.title if n.title else n.label }, "classes": " ".join(classes), - "selected": False # Wir deaktivieren die interne Selektion beim Init + # WICHTIG: Wir setzen selected immer auf False beim Init, + # damit wir nicht mit dem internen State des Browsers kämpfen. + # Die Visualisierung passiert über die Klasse .inspected + "selected": False } cy_elements.append(cy_node) + # Edges erstellen for e in edges_data: target_id = getattr(e, "to", getattr(e, "target", None)) if target_id: @@ -164,59 +189,71 @@ def render_graph_explorer_cytoscape(graph_service): } cy_elements.append(cy_edge) - # --- STYLESHEET (Klassen-basiert) --- + # --- 3.4 STYLESHEET --- stylesheet = [ + # BASIS NODE STYLE { "selector": "node", "style": { "label": "data(label)", - "width": "30px", "height": "30px", + "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": "90px", - "border-width": 2, "border-color": "#fff", + "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)" } }, - # Klasse .inspected = Gelber Rahmen (Ersetzt :selected) + # KLASSE: INSPECTED (Gelber Rahmen) - Ersetzt :selected { "selector": ".inspected", "style": { "border-width": 6, "border-color": "#FFC300", # Gelb/Gold - "width": "50px", "height": "50px", + "width": "50px", + "height": "50px", "font-weight": "bold", "z-index": 999 } }, - # Klasse .center = Roter Rahmen + # KLASSE: CENTER (Roter Rahmen) { "selector": ".center", "style": { "border-width": 4, "border-color": "#FF5733", # Rot - "width": "40px", "height": "40px" + "width": "40px", + "height": "40px" } }, - # Wenn beides zutrifft (Zentrum ist inspiziert) + # KOMBINATION: Center ist auch Inspected { "selector": ".center.inspected", "style": { "border-width": 6, - "border-color": "#FF5733", # Rot gewinnt oder Mix - "width": "55px", "height": "55px" + "border-color": "#FF5733", # Rot gewinnt (oder Mix) + "width": "55px", + "height": "55px" } }, - # Interne Selektion unsichtbar machen oder angleichen + # NATIVE SELEKTION (Unterdrücken/Anpassen) + # Wir machen den Standard-Selektionsrahmen unsichtbar(er), + # da wir .inspected nutzen. { "selector": "node:selected", "style": { - "overlay-opacity": 0, # Kein blauer Schleier + "overlay-opacity": 0, "border-width": 6, - "border-color": "#FFC300" # Feedback beim Klick + "border-color": "#FFC300" } }, + # EDGE STYLE { "selector": "edge", "style": { @@ -226,16 +263,20 @@ def render_graph_explorer_cytoscape(graph_service): "target-arrow-shape": "triangle", "curve-style": "bezier", "label": "data(label)", - "font-size": "10px", "color": "#666", - "text-background-opacity": 0.8, "text-background-color": "#fff" + "font-size": "10px", + "color": "#666", + "text-background-opacity": 0.8, + "text-background-color": "#fff" } } ] - # --- RENDER --- - # KEY-STRATEGIE: - # Der Key hängt NUR vom Zentrum und den Settings ab. - # Wenn sich inspected_id ändert, bleibt der Key GLEICH -> Kein Re-Layout, nur Style-Update. + # --- 3.5 RENDERING --- + # KEY STRATEGIE: + # Der Key darf NICHT von 'graph_inspected_id' abhängen. + # Er hängt nur von 'center_id' und Layout-Settings ab. + # Wenn wir eine Node anklicken, ändert sich inspected_id -> Rerun. + # Da der Key gleich bleibt, wird der Graph NICHT neu initialisiert -> Kein Springen! graph_key = f"cy_{center_id}_{st.session_state.cy_depth}_{st.session_state.cy_ideal_edge_len}" clicked_elements = cytoscape( @@ -248,7 +289,7 @@ def render_graph_explorer_cytoscape(graph_service): "refresh": 20, "fit": True, "padding": 50, - "randomize": False, # WICHTIG gegen Springen + "randomize": False, # WICHTIG für Stabilität "componentSpacing": 100, "nodeRepulsion": st.session_state.cy_node_repulsion, "edgeElasticity": 100, @@ -260,31 +301,22 @@ def render_graph_explorer_cytoscape(graph_service): "minTemp": 1.0, "animate": False }, - key=graph_key, + key=graph_key, height="700px" ) - # --- EVENT HANDLING --- + # --- 3.6 EVENT HANDLING --- if clicked_elements: clicked_nodes = clicked_elements.get("nodes", []) - - # Wir suchen nach der Node, die neu angeklickt wurde if clicked_nodes: - # Wir nehmen die letzte Node in der Liste (meistens die neu geklickte) - # oder filtern nach der, die nicht schon inspected ist - candidate_id = None + # Die Liste enthält die IDs der selektierten Knoten + clicked_id = clicked_nodes[0] - # Strategie: Wenn Liste > 1, nimm die ID die NICHT inspected_id ist - if len(clicked_nodes) > 1: - for nid in clicked_nodes: - if nid != st.session_state.graph_inspected_id: - candidate_id = nid - break - else: - candidate_id = clicked_nodes[0] - - if candidate_id and candidate_id != st.session_state.graph_inspected_id: - st.session_state.graph_inspected_id = candidate_id + # Wenn wir auf einen neuen Knoten klicken, ändern wir NUR die Inspektion. + # Das triggert einen Rerun. + # Da der graph_key gleich bleibt, wird nur der Style (.inspected Klasse) geupdated. + if clicked_id != st.session_state.graph_inspected_id: + st.session_state.graph_inspected_id = clicked_id st.rerun() else: