various: markdown improvements
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I81fda8247814da19eed1e76dbe97bd5b6a6a6964
This commit is contained in:
parent
875bdf5ebc
commit
80a8b5c7ca
23 changed files with 3458 additions and 30 deletions
295
crates/pinakes-ui/src/components/graph_view.rs
Normal file
295
crates/pinakes-ui/src/components/graph_view.rs
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
//! 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 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 {
|
||||
GraphSvg {
|
||||
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);
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// SVG-based graph rendering.
|
||||
#[component]
|
||||
fn GraphSvg(
|
||||
nodes: Vec<GraphNodeResponse>,
|
||||
edges: Vec<GraphEdgeResponse>,
|
||||
selected_node: Signal<Option<String>>,
|
||||
on_node_click: EventHandler<String>,
|
||||
on_node_double_click: EventHandler<String>,
|
||||
) -> 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;
|
||||
|
||||
// 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();
|
||||
|
||||
// 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();
|
||||
|
||||
let selected = selected_node.read();
|
||||
|
||||
rsx! {
|
||||
svg {
|
||||
class: "graph-svg",
|
||||
width: "{width}",
|
||||
height: "{height}",
|
||||
view_box: "0 0 {width} {height}",
|
||||
|
||||
// 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)",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
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()),
|
||||
|
||||
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",
|
||||
}
|
||||
text {
|
||||
x: "{x}",
|
||||
y: "{y + node_size + 15.0}",
|
||||
text_anchor: "middle",
|
||||
font_size: "12",
|
||||
fill: "#333",
|
||||
"{label}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(()),
|
||||
"\u{2715}"
|
||||
}
|
||||
}
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue