wayver's git archive


an obsidian renderer
git clone https://git.wayver.dev/sable

sable-renderer/src/templates/func_link_graph.rs@2b84405277e54ab809e328cf0237374d4b4dbd0c

raw
Date Commit Message Author Files + -
2026-02-23 01:55 initial mvp wayverd 139 17808 0
...

1use std::{collections::HashMap, fmt::Write as _, hash::Hasher};
2
3use fjadra::{
4    Link, Node, PositionX, PositionY, Simulation,
5    force::{Collide, SimulationBuilder},
6};
7use sable_vault::{Note, NoteContextOwned, SharedVault};
8use svg::{
9    Document, Node as _,
10    node::element::{Anchor, Circle, Group, Line, Text},
11};
12use tera::{Function, Value};
13
14pub(super) struct LinkGraph {
15    vault: SharedVault,
16}
17
18impl LinkGraph {
19    pub(super) const fn new(vault: SharedVault) -> Self {
20        Self { vault }
21    }
22}
23
24impl Function for LinkGraph {
25    #[allow(clippy::significant_drop_tightening)]
26    fn call(&self, args: &HashMap<String, Value>) -> tera::Result<Value> {
27        let Some(note) = args.get("note") else {
28            return Err(tera::Error::msg(
29                "Function `link_graph` was called without a `note` argument",
30            ));
31        };
32
33        // TODO: maybe use deserialize the index directly
34        let note = match serde_json::from_value::<NoteContextOwned>(note.clone()) {
35            Ok(note) => note,
36            Err(err) => {
37                return Err(tera::Error::msg(format!(
38                    "Function `link_graph` received an invalid `note` argument: {err}"
39                )));
40            }
41        };
42
43        // NOTE: clippy::significant_drop_tightening
44        let read = self.vault.read();
45
46        // TODO
47        let note = read.notes.get(&note.path).unwrap();
48
49        let references = read.get_note_references(note.index).collect::<Vec<_>>();
50
51        Ok(render_node_graph(&note, references.as_slice()).into())
52    }
53}
54
55trait GraphNode {
56    fn name(&self) -> String;
57    fn link(&self) -> String;
58}
59
60impl GraphNode for NoteContextOwned {
61    fn name(&self) -> String {
62        self.title.clone()
63    }
64
65    fn link(&self) -> String {
66        format!("/{}.html", self.path.slug)
67    }
68}
69
70impl GraphNode for &Note {
71    fn name(&self) -> String {
72        self.title().to_string()
73    }
74
75    fn link(&self) -> String {
76        format!("/{}.html", self.path().slug)
77    }
78}
79
80#[allow(clippy::cast_precision_loss)]
81fn render_node_graph<N: GraphNode>(center: &N, nodes: &[N]) -> String {
82    let (smallest, largest, simulation) = simulate(nodes);
83
84    let elements = build_svg_elements(center, nodes, &simulation);
85
86    let mut svg = Document::new().set("class", "sable-link-graph").set(
87        "viewBox",
88        (
89            smallest[0] - 10.0,
90            smallest[1] - 10.0,
91            (largest[0].abs() + smallest[0].abs()).abs() + 20.0,
92            (largest[1].abs() + smallest[1].abs()).abs() + 20.0,
93        ),
94    );
95
96    // for node in elements.connections {
97    //     svg.append(node);
98    // }
99    // for node in elements.nodes {
100    //     svg.append(node);
101    // }
102    // for node in elements.titles {
103    //     svg.append(node);
104    // }
105
106    // svg.append(svg::node::element::Style::new(elements.style));
107
108    for (i, pos) in simulation.positions().enumerate() {
109        if pos != [0.0, 0.0] {
110            svg.append(
111                Line::new()
112                    .set("id", hash(format!("{}-{}-{}-{}", 0.0, 0.0, pos[0], pos[1])))
113                    .set("class", "note-connection")
114                    .set("x1", 0.0)
115                    .set("y1", 0.0)
116                    .set("x2", pos[0])
117                    .set("y2", pos[1])
118                    .set("stroke", "gray"),
119            );
120        }
121
122        let note = if i == 0 { center } else { &nodes[i - 1] };
123
124        svg.append(
125            Group::new()
126                .set("id", hash(format!("{}-{}", pos[0], pos[1])))
127                .set("class", "note-container")
128                .add(
129                    Anchor::new()
130                        .set("class", "note-link")
131                        .set("href", note.link())
132                        .add(
133                            Circle::new()
134                                .set("class", "note-node")
135                                .set("r", 1.5)
136                                .set("cx", pos[0])
137                                .set("cy", pos[1])
138                                .set("fill", "black"),
139                        ),
140                )
141                .add(
142                    Text::new(note.name())
143                        .set("class", "note-text")
144                        .set("x", pos[0] + 2.0)
145                        .set("y", pos[1] + 0.65),
146                ),
147        );
148    }
149
150    svg.to_string()
151}
152
153fn simulate<N: GraphNode>(nodes: &[N]) -> ([f64; 2], [f64; 2], Simulation) {
154    let angle = nodes.len() as f64 / 360.0;
155
156    let links = nodes.iter().enumerate().map(|(i, _)| (0, i + 1));
157
158    let mut simulation = SimulationBuilder::default()
159        .with_velocity_decay(0.1)
160        .build(
161            std::iter::once(Node::from([0.0, 0.0]).fixed_position(0.0, 0.0)).chain(
162                (0..nodes.len()).enumerate().map(|(i, _)| {
163                    Node::from([
164                        1.0 * (angle * i as f64).cos(),
165                        1.0 * (angle * i as f64).sin(),
166                    ])
167                }),
168            ),
169        )
170        .add_force("collide", Collide::new().radius(|_| 5.0).iterations(3))
171        .add_force(
172            "link",
173            Link::new(links).strength(1.0).distance(10.0).iterations(10),
174        )
175        .add_force("x", PositionX::new())
176        .add_force("y", PositionY::new());
177
178    simulation.step();
179
180    let mut smallest = [f64::INFINITY, f64::INFINITY];
181    let mut largest = [f64::NEG_INFINITY, f64::NEG_INFINITY];
182
183    for node in simulation.positions() {
184        if node[0] < smallest[0] {
185            smallest[0] = node[0];
186        }
187        if node[1] < smallest[1] {
188            smallest[1] = node[1];
189        }
190        if node[0] > largest[0] {
191            largest[0] = node[0];
192        }
193        if node[1] > largest[1] {
194            largest[1] = node[1];
195        }
196    }
197
198    (smallest, largest, simulation)
199}
200
201#[derive(Default)]
202struct Elements {
203    ids: Vec<String>,
204
205    connections: Vec<Line>,
206    nodes: Vec<Anchor>,
207    titles: Vec<Text>,
208
209    style: String,
210}
211
212fn build_svg_elements<N: GraphNode>(center: &N, nodes: &[N], simulation: &Simulation) -> Elements {
213    let mut elements = Elements::default();
214
215    for (i, pos) in simulation.positions().enumerate() {
216        let note_id = hash(format!("{}-{}", pos[0], pos[1]));
217
218        if pos != [0.0, 0.0] {
219            let id = hash(format!("{}-{}-{}-{}", 0.0, 0.0, pos[0], pos[1]));
220
221            write!(
222                &mut elements.style,
223                ".note--{note_id}:hover ~ .note-connection--{note_id} {{}}"
224            )
225            .expect("failed to write to string");
226
227            elements.connections.push(
228                Line::new()
229                    .set("id", id)
230                    .set(
231                        "class",
232                        format!("note-connection note-connection--{note_id}"),
233                    )
234                    .set("x1", 0.0)
235                    .set("y1", 0.0)
236                    .set("x2", pos[0])
237                    .set("y2", pos[1])
238                    .set("stroke", "gray"),
239            );
240
241            elements.ids.push(note_id.clone());
242        }
243
244        let note = if i == 0 { center } else { &nodes[i - 1] };
245
246        elements.nodes.push(
247            Anchor::new()
248                .set("class", format!("note note--{note_id}"))
249                .set("href", note.link())
250                .add(
251                    Circle::new()
252                        .set("class", "note-node")
253                        .set("r", 1.5)
254                        .set("cx", pos[0])
255                        .set("cy", pos[1])
256                        .set("fill", "black"),
257                ),
258        );
259
260        write!(
261            &mut elements.style,
262            ".note--{note_id}:hover ~ .note-text--{note_id} {{opacity:100}}"
263        )
264        .expect("failed to write to string");
265
266        elements.titles.push(
267            Text::new(note.name())
268                .set("class", format!("note-text note-text--{note_id}"))
269                .set("x", pos[0] + 2.0)
270                .set("y", pos[1] + 0.65),
271        );
272    }
273
274    elements
275}
276
277#[allow(clippy::needless_pass_by_value)]
278fn hash(s: String) -> String {
279    let mut hasher = rustc_hash::FxHasher::default();
280
281    hasher.write(s.as_bytes());
282    hasher.write_usize(s.len());
283
284    data_encoding::BASE64URL_NOPAD.encode(&hasher.finish().to_le_bytes())
285}
286
287#[cfg(test)]
288mod test_render_node_graph {
289    use super::*;
290
291    struct NodeImpl;
292
293    impl GraphNode for NodeImpl {
294        fn name(&self) -> String {
295            "test".to_string()
296        }
297
298        fn link(&self) -> String {
299            String::new()
300        }
301    }
302
303    #[test]
304    #[ignore]
305    fn simple() {
306        let rendered = render_node_graph(&NodeImpl, &[NodeImpl, NodeImpl, NodeImpl]);
307
308        panic!("{rendered}");
309    }
310}
311