pinakes-ui: improve graph rendering; fix panic

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6d1af08cd1133fb2efefccdefa7ad3e36a6a6964
This commit is contained in:
raf 2026-02-10 12:50:06 +03:00
commit b4ffd56460
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF

View file

@ -1,9 +1,8 @@
//! Graph visualization component for markdown note connections. //! Graph visualization component for markdown note connections.
//! //!
//! Renders a force-directed graph showing connections between notes. //! Renders a force-directed graph showing connections between notes.
//! Uses a simple SVG-based rendering approach (no D3.js dependency).
use dioxus::prelude::*; use dioxus::prelude::*;
use std::collections::HashMap;
use crate::client::{ApiClient, GraphEdgeResponse, GraphNodeResponse, GraphResponse}; use crate::client::{ApiClient, GraphEdgeResponse, GraphNodeResponse, GraphResponse};
@ -92,7 +91,7 @@ pub fn GraphView(
"No linked notes found. Start creating links between your notes!" "No linked notes found. Start creating links between your notes!"
} }
} else { } else {
GraphSvg { ForceDirectedGraph {
nodes: graph.nodes.clone(), nodes: graph.nodes.clone(),
edges: graph.edges.clone(), edges: graph.edges.clone(),
selected_node: selected_node.clone(), selected_node: selected_node.clone(),
@ -126,50 +125,411 @@ pub fn GraphView(
} }
} }
/// SVG-based graph rendering. /// 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] #[component]
fn GraphSvg( fn ForceDirectedGraph(
nodes: Vec<GraphNodeResponse>, nodes: Vec<GraphNodeResponse>,
edges: Vec<GraphEdgeResponse>, edges: Vec<GraphEdgeResponse>,
selected_node: Signal<Option<String>>, selected_node: Signal<Option<String>>,
on_node_click: EventHandler<String>, on_node_click: EventHandler<String>,
on_node_double_click: EventHandler<String>, on_node_double_click: EventHandler<String>,
) -> Element { ) -> Element {
// Simple circular layout for nodes // Physics parameters (adjustable via controls)
let node_count = nodes.len(); let mut repulsion_strength = use_signal(|| 1000.0f64);
let width: f64 = 800.0; let mut link_strength = use_signal(|| 0.5f64);
let height: f64 = 600.0; let mut link_distance = use_signal(|| 100.0f64);
let center_x = width / 2.0; let mut center_strength = use_signal(|| 0.1f64);
let center_y = height / 2.0; let mut damping = use_signal(|| 0.8f64);
let radius = (width.min(height) / 2.0) - 60.0; let mut show_controls = use_signal(|| false);
let mut simulation_active = use_signal(|| true);
// Calculate node positions in a circle // View state
let positions: Vec<(f64, f64)> = (0..node_count) let mut zoom = use_signal(|| 1.0f64);
.map(|i| { let mut pan_x = use_signal(|| 0.0f64);
let angle = (i as f64 / node_count as f64) * 2.0 * std::f64::consts::PI; let mut pan_y = use_signal(|| 0.0f64);
let x = center_x + radius * angle.cos(); let mut is_dragging_canvas = use_signal(|| false);
let y = center_y + radius * angle.sin(); let mut drag_start_x = use_signal(|| 0.0f64);
(x, y) let mut drag_start_y = use_signal(|| 0.0f64);
}) let mut dragged_node = use_signal(|| Option::<String>::None);
.collect();
// Create a map from node id to position // Initialize physics nodes with random positions
let id_to_pos: std::collections::HashMap<&str, (f64, f64)> = nodes let mut physics_nodes = use_signal(|| {
nodes
.iter() .iter()
.enumerate() .map(|n| {
.map(|(i, n)| (n.id.as_str(), positions[i])) let angle = rand::random::<f64>() * 2.0 * std::f64::consts::PI;
.collect(); 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 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! { 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 { svg {
class: "graph-svg", class: "graph-svg",
width: "{width}", style: "width: 100%; height: 100%;",
height: "{height}", view_box: "-1000 -1000 2000 2000",
view_box: "0 0 {width} {height}", 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 edges first (so they appear behind nodes) // 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);
},
// 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", g { class: "graph-edges",
for edge in &edges { for edge in &edges {
if let (Some(&(x1, y1)), Some(&(x2, y2))) = ( if let (Some(&(x1, y1)), Some(&(x2, y2))) = (
@ -183,8 +543,9 @@ fn GraphSvg(
y1: "{y1}", y1: "{y1}",
x2: "{x2}", x2: "{x2}",
y2: "{y2}", y2: "{y2}",
stroke: "#888", stroke: "#666",
stroke_width: "1", stroke_width: "{1.5 / current_zoom}",
stroke_opacity: "0.6",
marker_end: "url(#arrowhead)", marker_end: "url(#arrowhead)",
} }
} }
@ -197,47 +558,65 @@ fn GraphSvg(
id: "arrowhead", id: "arrowhead",
marker_width: "10", marker_width: "10",
marker_height: "7", marker_height: "7",
ref_x: "10", ref_x: "9",
ref_y: "3.5", ref_y: "3.5",
orient: "auto", orient: "auto",
polygon { points: "0 0, 10 3.5, 0 7", fill: "#888" } polygon { points: "0 0, 10 3.5, 0 7", fill: "#666", fill_opacity: "0.6" }
} }
} }
// Draw nodes // Draw nodes
g { class: "graph-nodes", g { class: "graph-nodes",
for (i , node) in nodes.iter().enumerate() { for node in nodes_read.iter() {
{ {
let (x, y) = positions[i];
let node_id = node.id.clone(); let node_id = node.id.clone();
let node_id2 = node.id.clone(); let node_id2 = node.id.clone();
let label = node.label.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); 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); // 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! { rsx! {
g { g {
class: if is_selected { "graph-node selected" } else { "graph-node" }, class: if is_selected { "graph-node selected" } else { "graph-node" },
onclick: move |_| on_node_click.call(node_id.clone()), style: "cursor: pointer;",
ondoubleclick: move |_| on_node_double_click.call(node_id2.clone()), 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 { circle {
cx: "{x}", cx: "{node.x}",
cy: "{y}", cy: "{node.y}",
r: "{node_size}", r: "{scaled_radius}",
fill: if is_selected { "#2196f3" } else { "#4caf50" }, fill: if is_selected { "#2196f3" } else { "#4caf50" },
stroke: if is_selected { "#1565c0" } else { "#388e3c" }, stroke: if is_selected { "#1565c0" } else { "#2e7d32" },
stroke_width: "2", stroke_width: "{2.0 / current_zoom}",
} }
text { text {
x: "{x}", x: "{node.x}",
y: "{y + node_size + 15.0}", y: "{node.y + scaled_radius + 15.0 / current_zoom}",
text_anchor: "middle", text_anchor: "middle",
font_size: "12", font_size: "{12.0 / current_zoom}",
fill: "#333", fill: "#333",
"{label}" pointer_events: "none",
"{display_text}"
}
}
} }
} }
} }
@ -261,7 +640,7 @@ fn NodeDetailsPanel(
div { class: "node-details-panel", div { class: "node-details-panel",
div { class: "node-details-header", div { class: "node-details-header",
h3 { "{node.label}" } 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", div { class: "node-details-content",
if let Some(ref title) = node.title { if let Some(ref title) = node.title {