Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I21e7b73da193609c5d15b7f19d9668f96a6a6964
669 lines
27 KiB
Rust
669 lines
27 KiB
Rust
//! 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<String>,
|
||
on_navigate: EventHandler<String>,
|
||
) -> Element {
|
||
let mut graph_data = use_signal(|| Option::<GraphResponse>::None);
|
||
let mut loading = use_signal(|| true);
|
||
let mut error = use_signal(|| Option::<String>::None);
|
||
let mut depth = use_signal(|| 2u32);
|
||
let mut selected_node = use_signal(|| Option::<String>::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::<u32>() {
|
||
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<String>,
|
||
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<GraphNodeResponse>,
|
||
edges: Vec<GraphEdgeResponse>,
|
||
selected_node: Signal<Option<String>>,
|
||
on_node_click: EventHandler<String>,
|
||
on_node_double_click: EventHandler<String>,
|
||
) -> 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::<String>::None);
|
||
|
||
// Initialize physics nodes with random positions
|
||
let mut physics_nodes = use_signal(|| {
|
||
nodes
|
||
.iter()
|
||
.map(|n| {
|
||
let angle = rand::random::<f64>() * 2.0 * std::f64::consts::PI;
|
||
let radius = 100.0 + rand::random::<f64>() * 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::<Vec<_>>()
|
||
});
|
||
|
||
// 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::<f64>() {
|
||
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::<f64>() {
|
||
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::<f64>() {
|
||
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::<f64>() {
|
||
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::<f64>() {
|
||
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<String>,
|
||
) -> 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"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|