pinakes-ui: improve graph rendering; fix panic
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I6d1af08cd1133fb2efefccdefa7ad3e36a6a6964
This commit is contained in:
parent
55ee55fb31
commit
b4ffd56460
1 changed files with 476 additions and 97 deletions
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue