1use std::collections::BTreeMap;
23
24use serde::Serialize;
25
26use crate::graph::LinkGraph;
27
28#[derive(Debug, Clone, PartialEq, Serialize)]
30pub struct GraphNode {
31 pub slug: String,
32 pub title: String,
33 pub x: f64,
34 pub y: f64,
35 pub degree: u32,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
40pub struct GraphDataEdge {
41 pub from: String,
42 pub to: String,
43}
44
45#[derive(Debug, Clone, PartialEq, Serialize)]
47pub struct GraphData {
48 pub nodes: Vec<GraphNode>,
49 pub edges: Vec<GraphDataEdge>,
50}
51
52#[derive(Debug, Clone, Copy)]
54pub struct LayoutParams {
55 pub width: f64,
57 pub height: f64,
59 pub padding: f64,
61 pub iterations: usize,
63}
64
65impl Default for LayoutParams {
66 fn default() -> Self {
67 LayoutParams {
68 width: 1420.0,
69 height: 760.0,
70 padding: 74.0,
71 iterations: 180,
72 }
73 }
74}
75
76fn round2(v: f64) -> f64 {
79 (v * 100.0).round() / 100.0
80}
81
82fn seed_point(i: usize, total: usize, params: &LayoutParams) -> (f64, f64) {
90 let ga = std::f64::consts::PI * (3.0 - 5.0_f64.sqrt());
92 let angle = i as f64 * ga;
93 let radius_frac = ((i as f64 + 0.5) / total.max(1) as f64).sqrt();
94 let cx = params.width / 2.0;
95 let cy = params.height / 2.0;
96 let rmax = params.width.min(params.height) / 2.0 - params.padding;
97 let x = round2(cx + radius_frac * rmax * angle.cos());
100 let y = round2(cy + radius_frac * rmax * angle.sin());
101 (
102 x.clamp(params.padding, params.width - params.padding),
103 y.clamp(params.padding, params.height - params.padding),
104 )
105}
106
107pub fn layout_graph(
112 nodes_meta: &[(String, String)],
113 graph: &LinkGraph,
114 params: LayoutParams,
115) -> GraphData {
116 let n = nodes_meta.len();
117 if n == 0 {
118 return GraphData {
119 nodes: Vec::new(),
120 edges: Vec::new(),
121 };
122 }
123
124 let index_of: BTreeMap<&str, usize> = nodes_meta
126 .iter()
127 .enumerate()
128 .map(|(i, (s, _))| (s.as_str(), i))
129 .collect();
130
131 let mut edges_idx: Vec<(usize, usize)> = Vec::with_capacity(graph.edges.len());
137 let mut graph_edges: Vec<GraphDataEdge> = Vec::with_capacity(graph.edges.len());
138 let mut degree: Vec<u32> = vec![0; n];
139 let mut seen: std::collections::BTreeSet<(usize, usize)> = std::collections::BTreeSet::new();
140 for e in &graph.edges {
141 if e.from == e.to {
142 continue;
143 }
144 let (Some(&s), Some(&t)) = (index_of.get(e.from.as_str()), index_of.get(e.to.as_str()))
145 else {
146 continue;
147 };
148 let key = if s <= t { (s, t) } else { (t, s) };
150 if !seen.insert(key) {
151 continue;
152 }
153 edges_idx.push((s, t));
154 graph_edges.push(GraphDataEdge {
155 from: e.from.clone(),
156 to: e.to.clone(),
157 });
158 degree[s] += 1;
159 degree[t] += 1;
160 }
161
162 let seeds: Vec<(f64, f64)> = (0..n).map(|i| seed_point(i, n, ¶ms)).collect();
164 let (mut xs, mut ys): (Vec<f64>, Vec<f64>) = seeds.iter().copied().unzip();
165
166 for _ in 0..params.iterations {
169 for a in 0..n {
171 let (left, right) = xs.split_at_mut(a + 1);
174 let (lefty, righty) = ys.split_at_mut(a + 1);
175 let xa = &mut left[a];
176 let ya = &mut lefty[a];
177 for k in 0..right.len() {
178 let xb = &mut right[k];
179 let yb = &mut righty[k];
180 let mut dx = *xb - *xa;
181 let mut dy = *yb - *ya;
182 if dx == 0.0 {
183 dx = 0.01;
184 }
185 if dy == 0.0 {
186 dy = 0.01;
187 }
188 let dist_sq = dx * dx + dy * dy;
189 let dist = dist_sq.sqrt();
190 let strength = (2800.0 / dist_sq).min(3.8);
191 let px = dx / dist * strength;
192 let py = dy / dist * strength;
193 *xa -= px;
194 *ya -= py;
195 *xb += px;
196 *yb += py;
197 }
198 }
199 for &(s, t) in &edges_idx {
201 let dx = xs[t] - xs[s];
202 let dy = ys[t] - ys[s];
203 let dist = (dx * dx + dy * dy).sqrt().max(1.0);
204 let pull = (dist - 132.0) * 0.018;
205 let px = dx / dist * pull;
206 let py = dy / dist * pull;
207 xs[s] += px;
208 ys[s] += py;
209 xs[t] -= px;
210 ys[t] -= py;
211 }
212 for i in 0..n {
214 xs[i] += (seeds[i].0 - xs[i]) * 0.012;
215 ys[i] += (seeds[i].1 - ys[i]) * 0.006;
216 xs[i] = xs[i].clamp(params.padding, params.width - params.padding);
217 ys[i] = ys[i].clamp(params.padding, params.height - params.padding);
218 }
219 }
220
221 let nodes: Vec<GraphNode> = (0..n)
223 .map(|i| GraphNode {
224 slug: nodes_meta[i].0.clone(),
225 title: nodes_meta[i].1.clone(),
226 x: round2(xs[i]),
227 y: round2(ys[i]),
228 degree: degree[i],
229 })
230 .collect();
231
232 GraphData {
233 nodes,
234 edges: graph_edges,
235 }
236}
237
238pub fn graph_data_json(data: &GraphData) -> String {
240 serde_json::to_string(data).unwrap()
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246 use crate::model::LinkEdge;
247
248 fn lg(edges: &[(&str, &str)]) -> LinkGraph {
249 LinkGraph {
250 edges: edges
251 .iter()
252 .map(|(f, t)| LinkEdge {
253 from: f.to_string(),
254 to: t.to_string(),
255 })
256 .collect(),
257 backlinks: Default::default(),
258 }
259 }
260
261 #[test]
264 fn graphdata_serializes_with_stable_keys() {
265 let d = GraphData {
266 nodes: vec![GraphNode {
267 slug: "a".into(),
268 title: "A".into(),
269 x: 1.0,
270 y: 2.0,
271 degree: 3,
272 }],
273 edges: vec![GraphDataEdge {
274 from: "a".into(),
275 to: "b".into(),
276 }],
277 };
278 let j = graph_data_json(&d);
279 assert!(j.contains(r#""slug":"a""#));
280 assert!(j.contains(r#""x":1.0"#));
281 assert!(j.contains(r#""degree":3"#));
282 assert!(j.contains(r#""from":"a""#));
283 assert!(j.contains(r#""to":"b""#));
284 assert!(!j.contains(",\n"));
285 let _v: serde_json::Value = serde_json::from_str(&j).unwrap();
286 }
287
288 #[test]
291 fn degree_counts_each_incident_edge_undirected() {
292 let meta = vec![
293 ("a".into(), "A".into()),
294 ("b".into(), "B".into()),
295 ("c".into(), "C".into()),
296 ];
297 let g = lg(&[("a", "b"), ("a", "c"), ("b", "a")]);
299 let d = layout_graph(&meta, &g, LayoutParams::default());
300 let deg = |s: &str| d.nodes.iter().find(|n| n.slug == s).unwrap().degree;
301 assert_eq!(deg("a"), 2);
302 assert_eq!(deg("b"), 1);
303 assert_eq!(deg("c"), 1);
304 assert_eq!(d.edges.len(), 2);
306 }
307
308 #[test]
309 fn reciprocal_links_collapse_to_single_undirected_edge() {
310 let meta = vec![("a".into(), "A".into()), ("b".into(), "B".into())];
311 let g = lg(&[("a", "b"), ("b", "a")]);
312 let d = layout_graph(&meta, &g, LayoutParams::default());
313 assert_eq!(d.edges.len(), 1);
314 assert_eq!(d.nodes.iter().find(|n| n.slug == "a").unwrap().degree, 1);
315 assert_eq!(d.nodes.iter().find(|n| n.slug == "b").unwrap().degree, 1);
316 }
317
318 #[test]
319 fn self_loops_are_ignored_for_degree_and_edges() {
320 let meta = vec![("a".into(), "A".into()), ("b".into(), "B".into())];
321 let g = lg(&[("a", "a"), ("a", "b")]);
322 let d = layout_graph(&meta, &g, LayoutParams::default());
323 assert_eq!(d.nodes.iter().find(|n| n.slug == "a").unwrap().degree, 1);
324 assert!(!d.edges.iter().any(|e| e.from == e.to));
325 assert_eq!(d.edges.len(), 1);
326 }
327
328 #[test]
329 fn edges_to_unknown_slugs_are_dropped() {
330 let meta = vec![("a".into(), "A".into())];
331 let g = lg(&[("a", "ghost")]);
332 let d = layout_graph(&meta, &g, LayoutParams::default());
333 assert!(d.edges.is_empty());
334 assert_eq!(d.nodes.iter().find(|n| n.slug == "a").unwrap().degree, 0);
335 }
336
337 #[test]
340 fn seed_positions_are_deterministic_and_in_bounds() {
341 let meta: Vec<_> = (0..7).map(|i| (format!("n{i}"), format!("N{i}"))).collect();
342 let g = lg(&[]);
343 let p = LayoutParams::default();
344 let d1 = layout_graph(&meta, &g, p);
345 let d2 = layout_graph(&meta, &g, p);
346 assert_eq!(d1, d2);
347 for n in &d1.nodes {
348 assert!(
349 n.x >= p.padding && n.x <= p.width - p.padding,
350 "x oob: {}",
351 n.x
352 );
353 assert!(
354 n.y >= p.padding && n.y <= p.height - p.padding,
355 "y oob: {}",
356 n.y
357 );
358 }
359 }
360
361 #[test]
362 fn single_node_sits_inside_bounds_without_nan() {
363 let meta = vec![("only".into(), "Only".into())];
364 let d = layout_graph(&meta, &lg(&[]), LayoutParams::default());
365 assert_eq!(d.nodes.len(), 1);
366 let n = &d.nodes[0];
367 assert!(n.x.is_finite() && n.y.is_finite());
368 }
369
370 #[test]
371 fn empty_graph_yields_empty_graphdata() {
372 let d = layout_graph(&[], &lg(&[]), LayoutParams::default());
373 assert!(d.nodes.is_empty() && d.edges.is_empty());
374 let _v: serde_json::Value = serde_json::from_str(&graph_data_json(&d)).unwrap();
375 }
376
377 #[test]
380 fn connected_pair_settles_near_target_edge_length() {
381 let meta = vec![("a".into(), "A".into()), ("b".into(), "B".into())];
382 let g = lg(&[("a", "b")]);
383 let p = LayoutParams::default();
384 let d1 = layout_graph(&meta, &g, p);
385 let d2 = layout_graph(&meta, &g, p);
386 assert_eq!(d1, d2);
387 let a = &d1.nodes[0];
388 let b = &d1.nodes[1];
389 let dist = ((a.x - b.x).powi(2) + (a.y - b.y).powi(2)).sqrt();
390 assert!(
391 dist > 40.0 && dist < 320.0,
392 "pair distance {dist} not in plausible band"
393 );
394 for n in &d1.nodes {
395 assert!(n.x.is_finite() && n.y.is_finite());
396 }
397 }
398
399 #[test]
400 fn all_positions_stay_in_bounds_after_forces() {
401 let meta: Vec<_> = (0..20)
402 .map(|i| (format!("n{i}"), format!("N{i}")))
403 .collect();
404 let owned: Vec<(String, String)> =
405 (1..15).map(|i| ("n0".into(), format!("n{i}"))).collect();
406 let mut e: Vec<(&str, &str)> = Vec::new();
407 for (f, t) in &owned {
408 e.push((f, t));
409 }
410 let g = lg(&e);
411 let p = LayoutParams::default();
412 let d = layout_graph(&meta, &g, p);
413 for n in &d.nodes {
414 assert!(n.x >= p.padding - 0.01 && n.x <= p.width - p.padding + 0.01);
415 assert!(n.y >= p.padding - 0.01 && n.y <= p.height - p.padding + 0.01);
416 }
417 }
418
419 #[test]
420 fn disconnected_components_do_not_collapse_to_a_point() {
421 let meta = vec![
422 ("a".into(), "A".into()),
423 ("b".into(), "B".into()),
424 ("c".into(), "C".into()),
425 ("d".into(), "D".into()),
426 ];
427 let g = lg(&[("a", "b"), ("c", "d")]);
428 let d = layout_graph(&meta, &g, LayoutParams::default());
429 for i in 0..d.nodes.len() {
430 for j in (i + 1)..d.nodes.len() {
431 let (p, q) = (&d.nodes[i], &d.nodes[j]);
432 assert!(
433 (p.x - q.x).abs() > 0.01 || (p.y - q.y).abs() > 0.01,
434 "coincident nodes"
435 );
436 }
437 }
438 }
439
440 fn has_three_decimals(j: &str) -> bool {
442 let b = j.as_bytes();
443 for i in 0..b.len() {
444 if b[i] == b'.' {
445 let mut k = i + 1;
446 while k < b.len() && b[k].is_ascii_digit() {
447 k += 1;
448 }
449 if k - (i + 1) >= 3 {
450 return true;
451 }
452 }
453 }
454 false
455 }
456
457 #[test]
462 fn golden_three_node_layout_is_pinned_exactly() {
463 let meta = vec![
464 ("a".into(), "A".into()),
465 ("b".into(), "B".into()),
466 ("c".into(), "C".into()),
467 ];
468 let g = lg(&[("a", "b"), ("a", "c"), ("b", "a")]);
469 let j = graph_data_json(&layout_graph(&meta, &g, LayoutParams::default()));
470 assert_eq!(j, GOLDEN_THREE_NODE);
471 }
472
473 const GOLDEN_THREE_NODE: &str = "{\"nodes\":[{\"slug\":\"a\",\"title\":\"A\",\"x\":766.17,\"y\":358.41,\"degree\":2},{\"slug\":\"b\",\"title\":\"B\",\"x\":610.91,\"y\":459.58,\"degree\":1},{\"slug\":\"c\",\"title\":\"C\",\"x\":742.71,\"y\":189.89,\"degree\":1}],\"edges\":[{\"from\":\"a\",\"to\":\"b\"},{\"from\":\"a\",\"to\":\"c\"}]}";
474
475 #[test]
476 fn coordinates_are_rounded_for_stable_json() {
477 let meta: Vec<_> = (0..5).map(|i| (format!("n{i}"), format!("N{i}"))).collect();
478 let j = graph_data_json(&layout_graph(
479 &meta,
480 &lg(&[("n0", "n1")]),
481 LayoutParams::default(),
482 ));
483 assert!(!has_three_decimals(&j), "json has over-precise coords: {j}");
484 }
485}