pinakes/crates/pinakes-ui/src/components/graph_view.rs
NotAShelf 26db7279d6
pinakes-ui: format all rsx blocks
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I21e7b73da193609c5d15b7f19d9668f96a6a6964
2026-03-06 18:29:29 +03:00

669 lines
27 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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"
}
}
}
}
}