use std::collections::BTreeMap;
use serde::Serialize;
use crate::graph::LinkGraph;
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct GraphNode {
pub slug: String,
pub title: String,
pub x: f64,
pub y: f64,
pub degree: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GraphDataEdge {
pub from: String,
pub to: String,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct GraphData {
pub nodes: Vec<GraphNode>,
pub edges: Vec<GraphDataEdge>,
}
#[derive(Debug, Clone, Copy)]
pub struct LayoutParams {
pub width: f64,
pub height: f64,
pub padding: f64,
pub iterations: usize,
}
impl Default for LayoutParams {
fn default() -> Self {
LayoutParams {
width: 1420.0,
height: 760.0,
padding: 74.0,
iterations: 180,
}
}
}
fn round2(v: f64) -> f64 {
(v * 100.0).round() / 100.0
}
fn seed_point(i: usize, total: usize, params: &LayoutParams) -> (f64, f64) {
let ga = std::f64::consts::PI * (3.0 - 5.0_f64.sqrt());
let angle = i as f64 * ga;
let radius_frac = ((i as f64 + 0.5) / total.max(1) as f64).sqrt();
let cx = params.width / 2.0;
let cy = params.height / 2.0;
let rmax = params.width.min(params.height) / 2.0 - params.padding;
let x = round2(cx + radius_frac * rmax * angle.cos());
let y = round2(cy + radius_frac * rmax * angle.sin());
(
x.clamp(params.padding, params.width - params.padding),
y.clamp(params.padding, params.height - params.padding),
)
}
pub fn layout_graph(
nodes_meta: &[(String, String)],
graph: &LinkGraph,
params: LayoutParams,
) -> GraphData {
let n = nodes_meta.len();
if n == 0 {
return GraphData {
nodes: Vec::new(),
edges: Vec::new(),
};
}
let index_of: BTreeMap<&str, usize> = nodes_meta
.iter()
.enumerate()
.map(|(i, (s, _))| (s.as_str(), i))
.collect();
let mut edges_idx: Vec<(usize, usize)> = Vec::with_capacity(graph.edges.len());
let mut graph_edges: Vec<GraphDataEdge> = Vec::with_capacity(graph.edges.len());
let mut degree: Vec<u32> = vec![0; n];
let mut seen: std::collections::BTreeSet<(usize, usize)> = std::collections::BTreeSet::new();
for e in &graph.edges {
if e.from == e.to {
continue;
}
let (Some(&s), Some(&t)) = (index_of.get(e.from.as_str()), index_of.get(e.to.as_str()))
else {
continue;
};
let key = if s <= t { (s, t) } else { (t, s) };
if !seen.insert(key) {
continue;
}
edges_idx.push((s, t));
graph_edges.push(GraphDataEdge {
from: e.from.clone(),
to: e.to.clone(),
});
degree[s] += 1;
degree[t] += 1;
}
let seeds: Vec<(f64, f64)> = (0..n).map(|i| seed_point(i, n, ¶ms)).collect();
let (mut xs, mut ys): (Vec<f64>, Vec<f64>) = seeds.iter().copied().unzip();
for _ in 0..params.iterations {
for a in 0..n {
let (left, right) = xs.split_at_mut(a + 1);
let (lefty, righty) = ys.split_at_mut(a + 1);
let xa = &mut left[a];
let ya = &mut lefty[a];
for k in 0..right.len() {
let xb = &mut right[k];
let yb = &mut righty[k];
let mut dx = *xb - *xa;
let mut dy = *yb - *ya;
if dx == 0.0 {
dx = 0.01;
}
if dy == 0.0 {
dy = 0.01;
}
let dist_sq = dx * dx + dy * dy;
let dist = dist_sq.sqrt();
let strength = (2800.0 / dist_sq).min(3.8);
let px = dx / dist * strength;
let py = dy / dist * strength;
*xa -= px;
*ya -= py;
*xb += px;
*yb += py;
}
}
for &(s, t) in &edges_idx {
let dx = xs[t] - xs[s];
let dy = ys[t] - ys[s];
let dist = (dx * dx + dy * dy).sqrt().max(1.0);
let pull = (dist - 132.0) * 0.018;
let px = dx / dist * pull;
let py = dy / dist * pull;
xs[s] += px;
ys[s] += py;
xs[t] -= px;
ys[t] -= py;
}
for i in 0..n {
xs[i] += (seeds[i].0 - xs[i]) * 0.012;
ys[i] += (seeds[i].1 - ys[i]) * 0.006;
xs[i] = xs[i].clamp(params.padding, params.width - params.padding);
ys[i] = ys[i].clamp(params.padding, params.height - params.padding);
}
}
let nodes: Vec<GraphNode> = (0..n)
.map(|i| GraphNode {
slug: nodes_meta[i].0.clone(),
title: nodes_meta[i].1.clone(),
x: round2(xs[i]),
y: round2(ys[i]),
degree: degree[i],
})
.collect();
GraphData {
nodes,
edges: graph_edges,
}
}
pub fn graph_data_json(data: &GraphData) -> String {
serde_json::to_string(data).unwrap()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::LinkEdge;
fn lg(edges: &[(&str, &str)]) -> LinkGraph {
LinkGraph {
edges: edges
.iter()
.map(|(f, t)| LinkEdge {
from: f.to_string(),
to: t.to_string(),
})
.collect(),
backlinks: Default::default(),
}
}
#[test]
fn graphdata_serializes_with_stable_keys() {
let d = GraphData {
nodes: vec![GraphNode {
slug: "a".into(),
title: "A".into(),
x: 1.0,
y: 2.0,
degree: 3,
}],
edges: vec![GraphDataEdge {
from: "a".into(),
to: "b".into(),
}],
};
let j = graph_data_json(&d);
assert!(j.contains(r#""slug":"a""#));
assert!(j.contains(r#""x":1.0"#));
assert!(j.contains(r#""degree":3"#));
assert!(j.contains(r#""from":"a""#));
assert!(j.contains(r#""to":"b""#));
assert!(!j.contains(",\n"));
let _v: serde_json::Value = serde_json::from_str(&j).unwrap();
}
#[test]
fn degree_counts_each_incident_edge_undirected() {
let meta = vec![
("a".into(), "A".into()),
("b".into(), "B".into()),
("c".into(), "C".into()),
];
let g = lg(&[("a", "b"), ("a", "c"), ("b", "a")]);
let d = layout_graph(&meta, &g, LayoutParams::default());
let deg = |s: &str| d.nodes.iter().find(|n| n.slug == s).unwrap().degree;
assert_eq!(deg("a"), 2);
assert_eq!(deg("b"), 1);
assert_eq!(deg("c"), 1);
assert_eq!(d.edges.len(), 2);
}
#[test]
fn reciprocal_links_collapse_to_single_undirected_edge() {
let meta = vec![("a".into(), "A".into()), ("b".into(), "B".into())];
let g = lg(&[("a", "b"), ("b", "a")]);
let d = layout_graph(&meta, &g, LayoutParams::default());
assert_eq!(d.edges.len(), 1);
assert_eq!(d.nodes.iter().find(|n| n.slug == "a").unwrap().degree, 1);
assert_eq!(d.nodes.iter().find(|n| n.slug == "b").unwrap().degree, 1);
}
#[test]
fn self_loops_are_ignored_for_degree_and_edges() {
let meta = vec![("a".into(), "A".into()), ("b".into(), "B".into())];
let g = lg(&[("a", "a"), ("a", "b")]);
let d = layout_graph(&meta, &g, LayoutParams::default());
assert_eq!(d.nodes.iter().find(|n| n.slug == "a").unwrap().degree, 1);
assert!(!d.edges.iter().any(|e| e.from == e.to));
assert_eq!(d.edges.len(), 1);
}
#[test]
fn edges_to_unknown_slugs_are_dropped() {
let meta = vec![("a".into(), "A".into())];
let g = lg(&[("a", "ghost")]);
let d = layout_graph(&meta, &g, LayoutParams::default());
assert!(d.edges.is_empty());
assert_eq!(d.nodes.iter().find(|n| n.slug == "a").unwrap().degree, 0);
}
#[test]
fn seed_positions_are_deterministic_and_in_bounds() {
let meta: Vec<_> = (0..7).map(|i| (format!("n{i}"), format!("N{i}"))).collect();
let g = lg(&[]);
let p = LayoutParams::default();
let d1 = layout_graph(&meta, &g, p);
let d2 = layout_graph(&meta, &g, p);
assert_eq!(d1, d2);
for n in &d1.nodes {
assert!(
n.x >= p.padding && n.x <= p.width - p.padding,
"x oob: {}",
n.x
);
assert!(
n.y >= p.padding && n.y <= p.height - p.padding,
"y oob: {}",
n.y
);
}
}
#[test]
fn single_node_sits_inside_bounds_without_nan() {
let meta = vec![("only".into(), "Only".into())];
let d = layout_graph(&meta, &lg(&[]), LayoutParams::default());
assert_eq!(d.nodes.len(), 1);
let n = &d.nodes[0];
assert!(n.x.is_finite() && n.y.is_finite());
}
#[test]
fn empty_graph_yields_empty_graphdata() {
let d = layout_graph(&[], &lg(&[]), LayoutParams::default());
assert!(d.nodes.is_empty() && d.edges.is_empty());
let _v: serde_json::Value = serde_json::from_str(&graph_data_json(&d)).unwrap();
}
#[test]
fn connected_pair_settles_near_target_edge_length() {
let meta = vec![("a".into(), "A".into()), ("b".into(), "B".into())];
let g = lg(&[("a", "b")]);
let p = LayoutParams::default();
let d1 = layout_graph(&meta, &g, p);
let d2 = layout_graph(&meta, &g, p);
assert_eq!(d1, d2);
let a = &d1.nodes[0];
let b = &d1.nodes[1];
let dist = ((a.x - b.x).powi(2) + (a.y - b.y).powi(2)).sqrt();
assert!(
dist > 40.0 && dist < 320.0,
"pair distance {dist} not in plausible band"
);
for n in &d1.nodes {
assert!(n.x.is_finite() && n.y.is_finite());
}
}
#[test]
fn all_positions_stay_in_bounds_after_forces() {
let meta: Vec<_> = (0..20)
.map(|i| (format!("n{i}"), format!("N{i}")))
.collect();
let owned: Vec<(String, String)> =
(1..15).map(|i| ("n0".into(), format!("n{i}"))).collect();
let mut e: Vec<(&str, &str)> = Vec::new();
for (f, t) in &owned {
e.push((f, t));
}
let g = lg(&e);
let p = LayoutParams::default();
let d = layout_graph(&meta, &g, p);
for n in &d.nodes {
assert!(n.x >= p.padding - 0.01 && n.x <= p.width - p.padding + 0.01);
assert!(n.y >= p.padding - 0.01 && n.y <= p.height - p.padding + 0.01);
}
}
#[test]
fn disconnected_components_do_not_collapse_to_a_point() {
let meta = vec![
("a".into(), "A".into()),
("b".into(), "B".into()),
("c".into(), "C".into()),
("d".into(), "D".into()),
];
let g = lg(&[("a", "b"), ("c", "d")]);
let d = layout_graph(&meta, &g, LayoutParams::default());
for i in 0..d.nodes.len() {
for j in (i + 1)..d.nodes.len() {
let (p, q) = (&d.nodes[i], &d.nodes[j]);
assert!(
(p.x - q.x).abs() > 0.01 || (p.y - q.y).abs() > 0.01,
"coincident nodes"
);
}
}
}
fn has_three_decimals(j: &str) -> bool {
let b = j.as_bytes();
for i in 0..b.len() {
if b[i] == b'.' {
let mut k = i + 1;
while k < b.len() && b[k].is_ascii_digit() {
k += 1;
}
if k - (i + 1) >= 3 {
return true;
}
}
}
false
}
#[test]
fn golden_three_node_layout_is_pinned_exactly() {
let meta = vec![
("a".into(), "A".into()),
("b".into(), "B".into()),
("c".into(), "C".into()),
];
let g = lg(&[("a", "b"), ("a", "c"), ("b", "a")]);
let j = graph_data_json(&layout_graph(&meta, &g, LayoutParams::default()));
assert_eq!(j, GOLDEN_THREE_NODE);
}
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\"}]}";
#[test]
fn coordinates_are_rounded_for_stable_json() {
let meta: Vec<_> = (0..5).map(|i| (format!("n{i}"), format!("N{i}"))).collect();
let j = graph_data_json(&layout_graph(
&meta,
&lg(&[("n0", "n1")]),
LayoutParams::default(),
));
assert!(!has_three_decimals(&j), "json has over-precise coords: {j}");
}
}