various: markdown improvements

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I81fda8247814da19eed1e76dbe97bd5b6a6a6964
This commit is contained in:
raf 2026-02-05 15:39:05 +03:00
commit 80a8b5c7ca
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
23 changed files with 3458 additions and 30 deletions

View 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"
}
}
}
}
}