//! Graph visualization component for markdown note connections. //! //! Renders a force-directed graph showing connections between notes. use dioxus::prelude::*; use std::collections::HashMap; use crate::client::{ApiClient, GraphEdgeResponse, GraphNodeResponse, GraphResponse}; /// Graph view component showing note connections. #[component] pub fn GraphView( client: ApiClient, center_id: Option, on_navigate: EventHandler, ) -> Element { let mut graph_data = use_signal(|| Option::::None); let mut loading = use_signal(|| true); let mut error = use_signal(|| Option::::None); let mut depth = use_signal(|| 2u32); let mut selected_node = use_signal(|| Option::::None); // Fetch graph data let center = center_id.clone(); let d = *depth.read(); let client_clone = client.clone(); use_effect(move || { let center = center.clone(); let client = client_clone.clone(); spawn(async move { loading.set(true); error.set(None); match client.get_graph(center.as_deref(), Some(d)).await { Ok(resp) => { graph_data.set(Some(resp)); } Err(e) => { error.set(Some(format!("Failed to load graph: {e}"))); } } loading.set(false); }); }); let is_loading = *loading.read(); let current_depth = *depth.read(); let data = graph_data.read(); rsx! { div { class: "graph-view", // Toolbar div { class: "graph-toolbar", span { class: "graph-title", "Note Graph" } div { class: "graph-controls", label { "Depth: " } select { value: "{current_depth}", onchange: move |evt| { if let Ok(d) = evt.value().parse::() { depth.set(d); } }, option { value: "1", "1" } option { value: "2", "2" } option { value: "3", "3" } option { value: "4", "4" } option { value: "5", "5" } } } if let Some(ref data) = *data { div { class: "graph-stats", "{data.node_count} nodes, {data.edge_count} edges" } } } // Graph container div { class: "graph-container", if is_loading { div { class: "graph-loading", div { class: "spinner" } "Loading graph..." } } if let Some(ref err) = *error.read() { div { class: "graph-error", "{err}" } } if !is_loading && error.read().is_none() { if let Some(ref graph) = *data { if graph.nodes.is_empty() { div { class: "graph-empty", "No linked notes found. Start creating links between your notes!" } } else { ForceDirectedGraph { nodes: graph.nodes.clone(), edges: graph.edges.clone(), selected_node: selected_node.clone(), on_node_click: move |id: String| { selected_node.set(Some(id.clone())); }, on_node_double_click: move |id: String| { on_navigate.call(id); }, } } } } } // Node details panel if let Some(ref node_id) = *selected_node.read() { if let Some(ref graph) = *data { if let Some(node) = graph.nodes.iter().find(|n| &n.id == node_id) { NodeDetailsPanel { node: node.clone(), on_close: move |_| selected_node.set(None), on_navigate: move |id| { on_navigate.call(id); }, } } } } } } } /// Node with physics simulation state #[derive(Clone, Debug)] struct PhysicsNode { id: String, label: String, title: Option, link_count: usize, backlink_count: usize, x: f64, y: f64, vx: f64, vy: f64, } /// Force-directed graph with physics simulation #[component] fn ForceDirectedGraph( nodes: Vec, edges: Vec, selected_node: Signal>, on_node_click: EventHandler, on_node_double_click: EventHandler, ) -> Element { // Physics parameters (adjustable via controls) let mut repulsion_strength = use_signal(|| 1000.0f64); let mut link_strength = use_signal(|| 0.5f64); let mut link_distance = use_signal(|| 100.0f64); let mut center_strength = use_signal(|| 0.1f64); let mut damping = use_signal(|| 0.8f64); let mut show_controls = use_signal(|| false); let mut simulation_active = use_signal(|| true); // View state let mut zoom = use_signal(|| 1.0f64); let mut pan_x = use_signal(|| 0.0f64); let mut pan_y = use_signal(|| 0.0f64); let mut is_dragging_canvas = use_signal(|| false); let mut drag_start_x = use_signal(|| 0.0f64); let mut drag_start_y = use_signal(|| 0.0f64); let mut dragged_node = use_signal(|| Option::::None); // Initialize physics nodes with random positions let mut physics_nodes = use_signal(|| { nodes .iter() .map(|n| { let angle = rand::random::() * 2.0 * std::f64::consts::PI; let radius = 100.0 + rand::random::() * 200.0; PhysicsNode { id: n.id.clone(), label: n.label.clone(), title: n.title.clone(), link_count: n.link_count as usize, backlink_count: n.backlink_count as usize, x: radius * angle.cos(), y: radius * angle.sin(), vx: 0.0, vy: 0.0, } }) .collect::>() }); // Animation loop let edges_for_sim = edges.clone(); use_future(move || { let edges_for_sim = edges_for_sim.clone(); async move { loop { // Check simulation state let is_active = *simulation_active.peek(); let is_dragging = dragged_node.peek().is_some(); if is_active && !is_dragging { let mut nodes = physics_nodes.write(); let node_count = nodes.len(); if node_count > 0 { // Read physics parameters each frame let rep_strength = *repulsion_strength.peek(); let link_strength_val = *link_strength.peek(); let link_distance = *link_distance.peek(); let center_strength_val = *center_strength.peek(); let damping_val = *damping.peek(); // Apply forces for i in 0..node_count { let mut fx = 0.0; let mut fy = 0.0; // Repulsion between all nodes for j in 0..node_count { if i != j { let dx = nodes[i].x - nodes[j].x; let dy = nodes[i].y - nodes[j].y; let dist_sq = (dx * dx + dy * dy).max(1.0); let dist = dist_sq.sqrt(); let force = rep_strength / dist_sq; fx += (dx / dist) * force; fy += (dy / dist) * force; } } // Center force (pull towards origin) fx -= nodes[i].x * center_strength_val; fy -= nodes[i].y * center_strength_val; // Store force temporarily nodes[i].vx = fx; nodes[i].vy = fy; } // Attraction along edges for edge in &edges_for_sim { if let (Some(i), Some(j)) = ( nodes.iter().position(|n| n.id == edge.source), nodes.iter().position(|n| n.id == edge.target), ) { let dx = nodes[j].x - nodes[i].x; let dy = nodes[j].y - nodes[i].y; let dist = (dx * dx + dy * dy).sqrt().max(1.0); let force = (dist - link_distance) * link_strength_val; let fx = (dx / dist) * force; let fy = (dy / dist) * force; nodes[i].vx += fx; nodes[i].vy += fy; nodes[j].vx -= fx; nodes[j].vy -= fy; } } // Update positions with velocity and damping let mut total_kinetic_energy = 0.0; for node in nodes.iter_mut() { node.x += node.vx * 0.01; node.y += node.vy * 0.01; node.vx *= damping_val; node.vy *= damping_val; // Calculate kinetic energy (1/2 * m * v^2, assume m=1) let speed_sq = node.vx * node.vx + node.vy * node.vy; total_kinetic_energy += speed_sq; } // If total kinetic energy is below threshold, pause simulation let avg_kinetic_energy = total_kinetic_energy / node_count as f64; if avg_kinetic_energy < 0.01 { simulation_active.set(false); } } } // Sleep for ~16ms (60 FPS) tokio::time::sleep(tokio::time::Duration::from_millis(16)).await; } } }); let selected = selected_node.read(); let current_zoom = *zoom.read(); let current_pan_x = *pan_x.read(); let current_pan_y = *pan_y.read(); // Create id to position map let nodes_read = physics_nodes.read(); let id_to_pos: HashMap<&str, (f64, f64)> = nodes_read .iter() .map(|n| (n.id.as_str(), (n.x, n.y))) .collect(); rsx! { div { class: "graph-svg-container", // Zoom and physics controls div { class: "graph-zoom-controls", button { class: "zoom-btn", title: "Zoom In", onclick: move |_| { let new_zoom = (*zoom.read() * 1.2).min(5.0); zoom.set(new_zoom); }, "+" } button { class: "zoom-btn", title: "Zoom Out", onclick: move |_| { let new_zoom = (*zoom.read() / 1.2).max(0.1); zoom.set(new_zoom); }, "−" } button { class: "zoom-btn", title: "Reset View", onclick: move |_| { zoom.set(1.0); pan_x.set(0.0); pan_y.set(0.0); }, "⊙" } button { class: "zoom-btn", title: "Physics Settings", onclick: move |_| { let current = *show_controls.read(); show_controls.set(!current); }, "⚙" } } // Physics control panel if *show_controls.read() { div { class: "physics-controls-panel", h4 { "Physics Settings" } div { class: "control-group", label { "Repulsion Strength" } input { r#type: "range", min: "100", max: "5000", step: "100", value: "{*repulsion_strength.read()}", oninput: move |evt| { if let Ok(v) = evt.value().parse::() { repulsion_strength.set(v); simulation_active.set(true); } }, } span { class: "control-value", "{*repulsion_strength.read():.0}" } } div { class: "control-group", label { "Link Strength" } input { r#type: "range", min: "0.1", max: "2.0", step: "0.1", value: "{*link_strength.read()}", oninput: move |evt| { if let Ok(v) = evt.value().parse::() { link_strength.set(v); simulation_active.set(true); } }, } span { class: "control-value", "{*link_strength.read():.1}" } } div { class: "control-group", label { "Link Distance" } input { r#type: "range", min: "50", max: "300", step: "10", value: "{*link_distance.read()}", oninput: move |evt| { if let Ok(v) = evt.value().parse::() { link_distance.set(v); simulation_active.set(true); } }, } span { class: "control-value", "{*link_distance.read():.0}" } } div { class: "control-group", label { "Center Gravity" } input { r#type: "range", min: "0.01", max: "0.5", step: "0.01", value: "{*center_strength.read()}", oninput: move |evt| { if let Ok(v) = evt.value().parse::() { center_strength.set(v); simulation_active.set(true); } }, } span { class: "control-value", "{*center_strength.read():.2}" } } div { class: "control-group", label { "Damping" } input { r#type: "range", min: "0.5", max: "0.95", step: "0.05", value: "{*damping.read()}", oninput: move |evt| { if let Ok(v) = evt.value().parse::() { damping.set(v); simulation_active.set(true); } }, } span { class: "control-value", "{*damping.read():.2}" } } div { class: "control-group", label { "Simulation Status" } span { style: if *simulation_active.read() { "color: #4ade80;" } else { "color: #94a3b8;" }, if *simulation_active.read() { "Running" } else { "Paused (settled)" } } } button { class: "btn btn-sm btn-secondary", onclick: move |_| { simulation_active.set(true); }, disabled: *simulation_active.read(), "Restart Simulation" } button { class: "btn btn-sm btn-secondary", onclick: move |_| { repulsion_strength.set(1000.0); link_strength.set(0.5); link_distance.set(100.0); center_strength.set(0.1); damping.set(0.8); simulation_active.set(true); }, "Reset to Defaults" } } } // SVG canvas - fills available space svg { class: "graph-svg", style: "width: 100%; height: 100%;", view_box: "-1000 -1000 2000 2000", onmousedown: move |evt| { // Check if clicking on background (not a node) is_dragging_canvas.set(true); drag_start_x.set(evt.page_coordinates().x); drag_start_y.set(evt.page_coordinates().y); }, onmousemove: move |evt| { if *is_dragging_canvas.read() { let dx = (evt.page_coordinates().x - *drag_start_x.read()) / current_zoom; let dy = (evt.page_coordinates().y - *drag_start_y.read()) / current_zoom; pan_x.set(current_pan_x + dx); pan_y.set(current_pan_y + dy); drag_start_x.set(evt.page_coordinates().x); drag_start_y.set(evt.page_coordinates().y); } // Handle node dragging if let Some(ref node_id) = *dragged_node.read() { let mut nodes = physics_nodes.write(); if let Some(node) = nodes.iter_mut().find(|n| &n.id == node_id) { let dx = (evt.page_coordinates().x - *drag_start_x.read()) / current_zoom * 2.0; // Reset velocity when dragging let dy = (evt.page_coordinates().y - *drag_start_y.read()) / current_zoom * 2.0; node.x += dx; node.y += dy; node.vx = 0.0; node.vy = 0.0; drag_start_x.set(evt.page_coordinates().x); drag_start_y.set(evt.page_coordinates().y); } } }, onmouseup: move |_| { is_dragging_canvas.set(false); dragged_node.set(None); }, onmouseleave: move |_| { is_dragging_canvas.set(false); dragged_node.set(None); }, onwheel: move |evt| { let delta = if evt.delta().strip_units().y < 0.0 { 1.1 } else { 0.9 }; let new_zoom = (*zoom.read() * delta).max(0.1).min(5.0); zoom.set(new_zoom); }, // Transform group for zoom and pan g { transform: "translate({current_pan_x}, {current_pan_y}) scale({current_zoom})", // Draw edges first g { class: "graph-edges", for edge in &edges { if let (Some(&(x1, y1)), Some(&(x2, y2))) = ( id_to_pos.get(edge.source.as_str()), id_to_pos.get(edge.target.as_str()), ) { line { class: "graph-edge edge-type-{edge.link_type}", x1: "{x1}", y1: "{y1}", x2: "{x2}", y2: "{y2}", stroke: "#666", stroke_width: "{1.5 / current_zoom}", stroke_opacity: "0.6", marker_end: "url(#arrowhead)", } } } } // Arrow marker definition defs { marker { id: "arrowhead", marker_width: "10", marker_height: "7", ref_x: "9", ref_y: "3.5", orient: "auto", polygon { points: "0 0, 10 3.5, 0 7", fill: "#666", fill_opacity: "0.6", } } } // Draw nodes g { class: "graph-nodes", for node in nodes_read.iter() { { let node_id = node.id.clone(); let node_id2 = node.id.clone(); let node_id3 = node.id.clone(); let display_text = node.title.as_ref().unwrap_or(&node.label).clone(); let is_selected = selected.as_ref() == Some(&node.id); // Node size based on connections let total_links = node.link_count + node.backlink_count; let node_radius = 8.0 + (total_links as f64 * 1.5).min(20.0); let scaled_radius = node_radius / current_zoom; rsx! { g { class: if is_selected { "graph-node selected" } else { "graph-node" }, style: "cursor: pointer;", onclick: move |evt| { evt.stop_propagation(); on_node_click.call(node_id.clone()); }, ondoubleclick: move |evt| { evt.stop_propagation(); on_node_double_click.call(node_id2.clone()); }, onmousedown: move |evt| { evt.stop_propagation(); dragged_node.set(Some(node_id3.clone())); drag_start_x.set(evt.page_coordinates().x); drag_start_y.set(evt.page_coordinates().y); }, circle { cx: "{node.x}", cy: "{node.y}", r: "{scaled_radius}", fill: if is_selected { "#2196f3" } else { "#4caf50" }, stroke: if is_selected { "#1565c0" } else { "#2e7d32" }, stroke_width: "{2.0 / current_zoom}", } text { x: "{node.x}", y: "{node.y + scaled_radius + 15.0 / current_zoom}", text_anchor: "middle", font_size: "{12.0 / current_zoom}", fill: "#333", pointer_events: "none", "{display_text}" } } } } } } } } } } } /// Panel showing details about the selected node. #[component] fn NodeDetailsPanel( node: GraphNodeResponse, on_close: EventHandler<()>, on_navigate: EventHandler, ) -> Element { let node_id = node.id.clone(); rsx! { div { class: "node-details-panel", div { class: "node-details-header", h3 { "{node.label}" } button { class: "close-btn", onclick: move |_| on_close.call(()), "×" } } div { class: "node-details-content", if let Some(ref title) = node.title { p { class: "node-title", "{title}" } } div { class: "node-stats", span { class: "stat", "Outgoing: " strong { "{node.link_count}" } } span { class: "stat", "Incoming: " strong { "{node.backlink_count}" } } } button { class: "btn btn-primary", onclick: move |_| on_navigate.call(node_id.clone()), "Open Note" } } } } }