raw
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 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 let read = self.vault.read();
45
46 let note = read.notes.get(¬e.path).unwrap();
48
49 let references = read.get_note_references(note.index).collect::<Vec<_>>();
50
51 Ok(render_node_graph(¬e, 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
106
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