From b4ffd5646013df8d54037df6f4b32c4243b36577 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 10 Feb 2026 12:50:06 +0300 Subject: [PATCH] pinakes-ui: improve graph rendering; fix panic Signed-off-by: NotAShelf Change-Id: I6d1af08cd1133fb2efefccdefa7ad3e36a6a6964 --- .../pinakes-ui/src/components/graph_view.rs | 565 +++++++++++++++--- 1 file changed, 472 insertions(+), 93 deletions(-) diff --git a/crates/pinakes-ui/src/components/graph_view.rs b/crates/pinakes-ui/src/components/graph_view.rs index 6d83c44..7d6e5fa 100644 --- a/crates/pinakes-ui/src/components/graph_view.rs +++ b/crates/pinakes-ui/src/components/graph_view.rs @@ -1,9 +1,8 @@ //! Graph visualization component for markdown note connections. //! //! Renders a force-directed graph showing connections between notes. -//! Uses a simple SVG-based rendering approach (no D3.js dependency). - use dioxus::prelude::*; +use std::collections::HashMap; use crate::client::{ApiClient, GraphEdgeResponse, GraphNodeResponse, GraphResponse}; @@ -92,7 +91,7 @@ pub fn GraphView( "No linked notes found. Start creating links between your notes!" } } else { - GraphSvg { + ForceDirectedGraph { nodes: graph.nodes.clone(), edges: graph.edges.clone(), selected_node: selected_node.clone(), @@ -126,118 +125,498 @@ pub fn GraphView( } } -/// SVG-based graph rendering. +/// 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 GraphSvg( +fn ForceDirectedGraph( nodes: Vec, edges: Vec, selected_node: Signal>, on_node_click: EventHandler, on_node_double_click: EventHandler, ) -> Element { - // Simple circular layout for nodes - let node_count = nodes.len(); - let width: f64 = 800.0; - let height: f64 = 600.0; - let center_x = width / 2.0; - let center_y = height / 2.0; - let radius = (width.min(height) / 2.0) - 60.0; + // 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); - // Calculate node positions in a circle - let positions: Vec<(f64, f64)> = (0..node_count) - .map(|i| { - let angle = (i as f64 / node_count as f64) * 2.0 * std::f64::consts::PI; - let x = center_x + radius * angle.cos(); - let y = center_y + radius * angle.sin(); - (x, y) - }) - .collect(); + // 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); - // Create a map from node id to position - let id_to_pos: std::collections::HashMap<&str, (f64, f64)> = nodes - .iter() - .enumerate() - .map(|(i, n)| (n.id.as_str(), positions[i])) - .collect(); + // 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! { - svg { - class: "graph-svg", - width: "{width}", - height: "{height}", - view_box: "0 0 {width} {height}", + 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); + }, + "⚙" + } + } - // Draw edges first (so they appear behind nodes) - 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: "#888", - stroke_width: "1", - marker_end: "url(#arrowhead)", + // 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" } } } - // Arrow marker definition - defs { - marker { - id: "arrowhead", - marker_width: "10", - marker_height: "7", - ref_x: "10", - ref_y: "3.5", - orient: "auto", - polygon { points: "0 0, 10 3.5, 0 7", fill: "#888" } - } - } + // 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); + } - // Draw nodes - g { class: "graph-nodes", - for (i , node) in nodes.iter().enumerate() { - { - let (x, y) = positions[i]; - let node_id = node.id.clone(); - let node_id2 = node.id.clone(); - let label = node.label.clone(); - let is_selected = selected.as_ref() == Some(&node.id); - let node_size = 8.0 + (node.link_count + node.backlink_count) as f64 * 2.0; - let node_size = node_size.min(30.0); + // 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; + let dy = (evt.page_coordinates().y - *drag_start_y.read()) / current_zoom * 2.0; + node.x += dx; + node.y += dy; + // Reset velocity when dragging + 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); + }, - rsx! { - g { - class: if is_selected { "graph-node selected" } else { "graph-node" }, - onclick: move |_| on_node_click.call(node_id.clone()), - ondoubleclick: move |_| on_node_double_click.call(node_id2.clone()), + // Transform group for zoom and pan + g { transform: "translate({current_pan_x}, {current_pan_y}) scale({current_zoom})", - - circle { - cx: "{x}", - cy: "{y}", - r: "{node_size}", - fill: if is_selected { "#2196f3" } else { "#4caf50" }, - stroke: if is_selected { "#1565c0" } else { "#388e3c" }, - stroke_width: "2", + // 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)", } - text { - x: "{x}", - y: "{y + node_size + 15.0}", - text_anchor: "middle", - font_size: "12", - fill: "#333", - "{label}" + } + } + } + + // 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}" + } + } } } } @@ -261,7 +640,7 @@ fn NodeDetailsPanel( div { class: "node-details-panel", div { class: "node-details-header", h3 { "{node.label}" } - button { class: "close-btn", onclick: move |_| on_close.call(()), "\u{2715}" } + button { class: "close-btn", onclick: move |_| on_close.call(()), "×" } } div { class: "node-details-content", if let Some(ref title) = node.title {