radial sugiyama positioning integration

This commit is contained in:
Oxy8
2026-03-23 11:13:27 -03:00
parent 6b9115e43b
commit 696844f341
51 changed files with 10089 additions and 364 deletions

View File

@@ -0,0 +1,38 @@
use std::io::{self, Read, Write};
use std::process::ExitCode;
use radial_sugiyama::{
process_go_bridge_request_with_options, BridgeRuntimeConfig, EnvConfig, GoBridgeRequest,
};
fn main() -> ExitCode {
if let Err(error) = run() {
eprintln!("{error}");
return ExitCode::FAILURE;
}
ExitCode::SUCCESS
}
fn run() -> Result<(), Box<dyn std::error::Error>> {
let env_config = EnvConfig::from_env()?;
let mut input = String::new();
io::stdin().read_to_string(&mut input)?;
let request: GoBridgeRequest = serde_json::from_str(&input)?;
let response = process_go_bridge_request_with_options(
request,
BridgeRuntimeConfig {
layout: env_config.layout,
svg: env_config.svg,
svg_output_path: Some(env_config.output_path()),
canonicalize_input: true,
},
)?;
let stdout = io::stdout();
let mut handle = stdout.lock();
serde_json::to_writer(&mut handle, &response)?;
handle.write_all(b"\n")?;
Ok(())
}

View File

@@ -0,0 +1,784 @@
use std::collections::{HashMap, HashSet, VecDeque};
use std::error::Error;
use std::fmt::{Display, Formatter};
use std::fs::create_dir_all;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::{
layout_radial_hierarchy_with_artifacts, write_svg_path_with_options, Edge, EdgeRouteKind,
Graph, LayoutArtifacts, LayoutConfig, LayoutError, Node, SvgConfig, SvgExportError,
};
#[derive(Debug)]
pub enum BridgeError {
DuplicateNodeId {
node_id: u32,
},
DuplicateEdgeIndex {
edge_index: usize,
},
MissingNodeRef {
edge_index: usize,
node_id: u32,
},
RootNotFound {
root_iri: String,
},
NoDescendants {
root_iri: String,
},
CreateOutputDir {
path: PathBuf,
source: std::io::Error,
},
SvgExport(SvgExportError),
Layout(LayoutError),
}
impl Display for BridgeError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
BridgeError::DuplicateNodeId { node_id } => {
write!(f, "bridge request contains duplicate node_id {node_id}")
}
BridgeError::DuplicateEdgeIndex { edge_index } => {
write!(
f,
"bridge request contains duplicate edge_index {edge_index}"
)
}
BridgeError::MissingNodeRef {
edge_index,
node_id,
} => {
write!(
f,
"bridge request edge {edge_index} references unknown node_id {node_id}"
)
}
BridgeError::RootNotFound { root_iri } => {
write!(
f,
"root class IRI {root_iri} was not found in the bridge graph"
)
}
BridgeError::NoDescendants { root_iri } => {
write!(
f,
"root class IRI {root_iri} has no subclass descendants in the bridge graph"
)
}
BridgeError::CreateOutputDir { path, source } => write!(
f,
"failed to create SVG output directory {}: {source}",
path.display()
),
BridgeError::SvgExport(error) => Display::fmt(error, f),
BridgeError::Layout(error) => Display::fmt(error, f),
}
}
}
impl Error for BridgeError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
BridgeError::CreateOutputDir { source, .. } => Some(source),
BridgeError::SvgExport(error) => Some(error),
BridgeError::Layout(error) => Some(error),
_ => None,
}
}
}
impl From<LayoutError> for BridgeError {
fn from(value: LayoutError) -> Self {
Self::Layout(value)
}
}
impl From<SvgExportError> for BridgeError {
fn from(value: SvgExportError) -> Self {
Self::SvgExport(value)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct BridgeRuntimeConfig {
pub layout: LayoutConfig,
pub svg: SvgConfig,
pub svg_output_path: Option<PathBuf>,
pub canonicalize_input: bool,
}
impl BridgeRuntimeConfig {
pub fn json_only(layout: LayoutConfig) -> Self {
Self {
layout,
svg: SvgConfig::default(),
svg_output_path: None,
canonicalize_input: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GoBridgeRequest {
pub root_iri: String,
pub nodes: Vec<GoBridgeNode>,
pub edges: Vec<GoBridgeEdge>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GoBridgeNode {
pub node_id: u32,
pub iri: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GoBridgeEdge {
pub edge_index: usize,
pub parent_id: u32,
pub child_id: u32,
#[serde(default)]
pub predicate_iri: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GoBridgeResponse {
pub nodes: Vec<GoBridgeRoutedNode>,
pub route_segments: Vec<GoBridgeRouteSegment>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GoBridgeRoutedNode {
pub node_id: u32,
pub x: f64,
pub y: f64,
pub level: usize,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GoBridgeRouteSegment {
pub edge_index: usize,
pub kind: String,
pub points: Vec<GoBridgePoint>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GoBridgePoint {
pub x: f64,
pub y: f64,
}
struct BridgeGraph {
root_iri: String,
graph: Graph,
node_ids: Vec<u32>,
edge_indices: Vec<usize>,
}
pub fn process_go_bridge_request(
request: GoBridgeRequest,
config: LayoutConfig,
) -> Result<GoBridgeResponse, BridgeError> {
process_go_bridge_request_with_options(request, BridgeRuntimeConfig::json_only(config))
}
pub fn process_go_bridge_request_with_options(
request: GoBridgeRequest,
config: BridgeRuntimeConfig,
) -> Result<GoBridgeResponse, BridgeError> {
let bridge_graph = build_bridge_graph(request)?;
let mut filtered = filter_bridge_graph_to_descendants(bridge_graph)?;
if config.canonicalize_input {
filtered = canonicalize_bridge_graph(filtered);
}
let artifacts = layout_radial_hierarchy_with_artifacts(&mut filtered.graph, config.layout)?;
write_debug_svg_if_configured(&filtered.graph, &artifacts, &config)?;
Ok(build_bridge_response(&filtered, &artifacts))
}
fn build_bridge_graph(request: GoBridgeRequest) -> Result<BridgeGraph, BridgeError> {
let mut node_id_to_index = HashMap::with_capacity(request.nodes.len());
let mut nodes = Vec::with_capacity(request.nodes.len());
let mut node_ids = Vec::with_capacity(request.nodes.len());
for node in request.nodes {
if node_id_to_index.insert(node.node_id, nodes.len()).is_some() {
return Err(BridgeError::DuplicateNodeId {
node_id: node.node_id,
});
}
nodes.push(Node {
label: Some(node.iri),
..Node::default()
});
node_ids.push(node.node_id);
}
let mut seen_edge_indices = HashSet::with_capacity(request.edges.len());
let mut edges = Vec::with_capacity(request.edges.len());
let mut edge_indices = Vec::with_capacity(request.edges.len());
for edge in request.edges {
if !seen_edge_indices.insert(edge.edge_index) {
return Err(BridgeError::DuplicateEdgeIndex {
edge_index: edge.edge_index,
});
}
let Some(&source) = node_id_to_index.get(&edge.parent_id) else {
return Err(BridgeError::MissingNodeRef {
edge_index: edge.edge_index,
node_id: edge.parent_id,
});
};
let Some(&target) = node_id_to_index.get(&edge.child_id) else {
return Err(BridgeError::MissingNodeRef {
edge_index: edge.edge_index,
node_id: edge.child_id,
});
};
edges.push(Edge::new(source, target));
edge_indices.push(edge.edge_index);
}
Ok(BridgeGraph {
root_iri: request.root_iri,
graph: Graph::new(nodes, edges),
node_ids,
edge_indices,
})
}
fn filter_bridge_graph_to_descendants(
bridge_graph: BridgeGraph,
) -> Result<BridgeGraph, BridgeError> {
let BridgeGraph {
root_iri,
graph,
node_ids,
edge_indices,
} = bridge_graph;
let Some(root_index) = graph
.nodes
.iter()
.position(|node| node.label.as_deref() == Some(root_iri.as_str()))
else {
return Err(BridgeError::RootNotFound { root_iri });
};
let mut adjacency = vec![Vec::new(); graph.nodes.len()];
for edge in &graph.edges {
adjacency[edge.source].push(edge.target);
}
let mut visited = HashSet::from([root_index]);
let mut queue = VecDeque::from([root_index]);
while let Some(node) = queue.pop_front() {
for &child in &adjacency[node] {
if visited.insert(child) {
queue.push_back(child);
}
}
}
if visited.len() <= 1 {
return Err(BridgeError::NoDescendants { root_iri });
}
let mut reindex = HashMap::with_capacity(visited.len());
let mut filtered_nodes = Vec::with_capacity(visited.len());
let mut filtered_node_ids = Vec::with_capacity(visited.len());
for (old_index, node) in graph.nodes.iter().enumerate() {
if !visited.contains(&old_index) {
continue;
}
let new_index = filtered_nodes.len();
reindex.insert(old_index, new_index);
filtered_nodes.push(node.clone());
filtered_node_ids.push(node_ids[old_index]);
}
let mut filtered_edges = Vec::new();
let mut filtered_edge_indices = Vec::new();
for (old_edge_index, edge) in graph.edges.iter().enumerate() {
if !visited.contains(&edge.source) || !visited.contains(&edge.target) {
continue;
}
filtered_edges.push(Edge::new(reindex[&edge.source], reindex[&edge.target]));
filtered_edge_indices.push(edge_indices[old_edge_index]);
}
Ok(BridgeGraph {
root_iri,
graph: Graph::new(filtered_nodes, filtered_edges),
node_ids: filtered_node_ids,
edge_indices: filtered_edge_indices,
})
}
fn canonicalize_bridge_graph(bridge_graph: BridgeGraph) -> BridgeGraph {
let BridgeGraph {
root_iri,
graph,
node_ids,
edge_indices,
} = bridge_graph;
let mut node_order = (0..graph.nodes.len()).collect::<Vec<_>>();
node_order.sort_by(|left, right| {
graph.nodes[*left]
.label
.as_deref()
.unwrap_or("")
.cmp(graph.nodes[*right].label.as_deref().unwrap_or(""))
.then(node_ids[*left].cmp(&node_ids[*right]))
});
let mut reindex = vec![0usize; graph.nodes.len()];
let mut nodes = Vec::with_capacity(graph.nodes.len());
let mut canonical_node_ids = Vec::with_capacity(node_ids.len());
for (new_index, old_index) in node_order.into_iter().enumerate() {
reindex[old_index] = new_index;
nodes.push(graph.nodes[old_index].clone());
canonical_node_ids.push(node_ids[old_index]);
}
let mut edge_order = (0..graph.edges.len()).collect::<Vec<_>>();
edge_order.sort_by(|left, right| {
let left_edge = graph.edges[*left];
let right_edge = graph.edges[*right];
graph.nodes[left_edge.source]
.label
.as_deref()
.unwrap_or("")
.cmp(
graph.nodes[right_edge.source]
.label
.as_deref()
.unwrap_or(""),
)
.then(
graph.nodes[left_edge.target]
.label
.as_deref()
.unwrap_or("")
.cmp(
graph.nodes[right_edge.target]
.label
.as_deref()
.unwrap_or(""),
),
)
.then(edge_indices[*left].cmp(&edge_indices[*right]))
});
let mut edges = Vec::with_capacity(graph.edges.len());
let mut canonical_edge_indices = Vec::with_capacity(edge_indices.len());
for old_edge_index in edge_order {
let edge = graph.edges[old_edge_index];
edges.push(Edge::new(reindex[edge.source], reindex[edge.target]));
canonical_edge_indices.push(edge_indices[old_edge_index]);
}
BridgeGraph {
root_iri,
graph: Graph::new(nodes, edges),
node_ids: canonical_node_ids,
edge_indices: canonical_edge_indices,
}
}
fn write_debug_svg_if_configured(
graph: &Graph,
artifacts: &LayoutArtifacts,
config: &BridgeRuntimeConfig,
) -> Result<(), BridgeError> {
let Some(path) = &config.svg_output_path else {
return Ok(());
};
if let Some(parent) = path.parent() {
create_dir_all(parent).map_err(|source| BridgeError::CreateOutputDir {
path: parent.to_path_buf(),
source,
})?;
}
write_svg_path_with_options(path, graph, artifacts, config.layout, config.svg)?;
Ok(())
}
fn build_bridge_response(graph: &BridgeGraph, artifacts: &LayoutArtifacts) -> GoBridgeResponse {
let nodes = graph
.graph
.nodes
.iter()
.enumerate()
.map(|(node_index, node)| GoBridgeRoutedNode {
node_id: graph.node_ids[node_index],
x: node.x,
y: node.y,
level: artifacts.node_levels[node_index],
})
.collect::<Vec<_>>();
let route_segments = artifacts
.edge_routes
.iter()
.map(|route| GoBridgeRouteSegment {
edge_index: graph.edge_indices[route.original_edge_index],
kind: route_kind_name(route.kind).to_owned(),
points: route
.points
.iter()
.map(|point| GoBridgePoint {
x: point.x,
y: point.y,
})
.collect(),
})
.collect::<Vec<_>>();
GoBridgeResponse {
nodes,
route_segments,
}
}
fn route_kind_name(kind: EdgeRouteKind) -> &'static str {
match kind {
EdgeRouteKind::Straight => "straight",
EdgeRouteKind::Spiral => "spiral",
EdgeRouteKind::IntraLevel => "intra_level",
}
}
#[cfg(test)]
mod tests {
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
use super::*;
use crate::{filter_graph_to_descendants, RingDistribution};
fn node(node_id: u32, iri: &str) -> GoBridgeNode {
GoBridgeNode {
node_id,
iri: iri.to_owned(),
}
}
fn edge(edge_index: usize, parent_id: u32, child_id: u32) -> GoBridgeEdge {
GoBridgeEdge {
edge_index,
parent_id,
child_id,
predicate_iri: None,
}
}
fn runtime_config() -> BridgeRuntimeConfig {
BridgeRuntimeConfig {
layout: LayoutConfig {
ring_distribution: RingDistribution::Adaptive,
..LayoutConfig::default()
},
svg: SvgConfig {
shortest_edges: false,
show_labels: false,
},
svg_output_path: None,
canonicalize_input: true,
}
}
fn sorted_nodes(mut nodes: Vec<GoBridgeRoutedNode>) -> Vec<(u32, usize, i64, i64)> {
nodes.sort_by_key(|node| node.node_id);
nodes
.into_iter()
.map(|node| {
(
node.node_id,
node.level,
(node.x * 1_000_000.0).round() as i64,
(node.y * 1_000_000.0).round() as i64,
)
})
.collect()
}
fn sorted_segments(
mut segments: Vec<GoBridgeRouteSegment>,
) -> Vec<(usize, String, Vec<(i64, i64)>)> {
segments.sort_by(|left, right| {
left.edge_index
.cmp(&right.edge_index)
.then(left.kind.cmp(&right.kind))
.then(left.points.len().cmp(&right.points.len()))
});
segments
.into_iter()
.map(|segment| {
(
segment.edge_index,
segment.kind,
segment
.points
.into_iter()
.map(|point| {
(
(point.x * 1_000_000.0).round() as i64,
(point.y * 1_000_000.0).round() as i64,
)
})
.collect(),
)
})
.collect()
}
#[test]
fn filters_to_root_descendants_and_preserves_node_ids() {
let response = process_go_bridge_request_with_options(
GoBridgeRequest {
root_iri: "root".to_owned(),
nodes: vec![
node(10, "root"),
node(11, "child"),
node(12, "leaf"),
node(13, "other"),
],
edges: vec![edge(0, 10, 11), edge(1, 11, 12), edge(2, 13, 12)],
},
runtime_config(),
)
.unwrap();
let mut kept_ids = response
.nodes
.iter()
.map(|node| node.node_id)
.collect::<Vec<_>>();
kept_ids.sort();
assert_eq!(kept_ids, vec![10, 11, 12]);
assert!(response
.route_segments
.iter()
.all(|segment| segment.edge_index == 0 || segment.edge_index == 1));
}
#[test]
fn returns_multiple_route_segments_for_long_edges_with_dummies() {
let response = process_go_bridge_request_with_options(
GoBridgeRequest {
root_iri: "root".to_owned(),
nodes: vec![node(1, "root"), node(2, "child"), node(3, "leaf")],
edges: vec![edge(10, 1, 2), edge(11, 2, 3), edge(12, 1, 3)],
},
runtime_config(),
)
.unwrap();
let long_edge_routes = response
.route_segments
.iter()
.filter(|segment| segment.edge_index == 12)
.count();
assert!(long_edge_routes >= 2);
}
#[test]
fn returns_error_when_root_is_missing() {
let error = process_go_bridge_request_with_options(
GoBridgeRequest {
root_iri: "root".to_owned(),
nodes: vec![node(1, "other")],
edges: vec![],
},
runtime_config(),
)
.unwrap_err();
assert!(matches!(
error,
BridgeError::RootNotFound { root_iri } if root_iri == "root"
));
}
#[test]
fn returns_error_when_root_has_no_descendants() {
let error = process_go_bridge_request_with_options(
GoBridgeRequest {
root_iri: "root".to_owned(),
nodes: vec![node(1, "root"), node(2, "other")],
edges: vec![edge(0, 2, 1)],
},
runtime_config(),
)
.unwrap_err();
assert!(matches!(
error,
BridgeError::NoDescendants { root_iri } if root_iri == "root"
));
}
#[test]
fn bridge_matches_direct_layout_for_same_graph_and_config() {
let request = GoBridgeRequest {
root_iri: "root".to_owned(),
nodes: vec![
node(5, "leaf"),
node(1, "root"),
node(4, "sibling"),
node(2, "child"),
node(3, "grandchild"),
],
edges: vec![
edge(12, 2, 3),
edge(10, 1, 2),
edge(11, 1, 4),
edge(13, 1, 5),
],
};
let config = runtime_config();
let response =
process_go_bridge_request_with_options(request.clone(), config.clone()).unwrap();
let node_index_by_id = request
.nodes
.iter()
.enumerate()
.map(|(index, node)| (node.node_id, index))
.collect::<HashMap<_, _>>();
let direct_edges = request
.edges
.iter()
.map(|edge| {
Edge::new(
*node_index_by_id.get(&edge.parent_id).unwrap(),
*node_index_by_id.get(&edge.child_id).unwrap(),
)
})
.collect::<Vec<_>>();
let direct_nodes = request
.nodes
.iter()
.map(|node| Node {
label: Some(node.iri.clone()),
..Node::default()
})
.collect::<Vec<_>>();
let direct_graph = Graph::new(direct_nodes, direct_edges);
let filtered_direct =
filter_graph_to_descendants(&direct_graph, &request.root_iri).unwrap();
let mut filtered =
filter_bridge_graph_to_descendants(build_bridge_graph(request).unwrap()).unwrap();
filtered = canonicalize_bridge_graph(filtered);
let mut direct_expected_iris = filtered_direct
.nodes
.iter()
.filter_map(|node| node.label.clone())
.collect::<Vec<_>>();
let mut actual_iris = filtered
.graph
.nodes
.iter()
.filter_map(|node| node.label.clone())
.collect::<Vec<_>>();
direct_expected_iris.sort();
actual_iris.sort();
assert_eq!(actual_iris, direct_expected_iris);
let artifacts =
layout_radial_hierarchy_with_artifacts(&mut filtered.graph, config.layout).unwrap();
let expected = build_bridge_response(&filtered, &artifacts);
assert_eq!(sorted_nodes(response.nodes), sorted_nodes(expected.nodes));
assert_eq!(
sorted_segments(response.route_segments),
sorted_segments(expected.route_segments)
);
}
#[test]
fn canonicalization_makes_bridge_positions_independent_of_input_order() {
let config = runtime_config();
let request_a = GoBridgeRequest {
root_iri: "root".to_owned(),
nodes: vec![
node(1, "root"),
node(2, "child"),
node(3, "leaf"),
node(4, "sibling"),
],
edges: vec![edge(0, 1, 2), edge(1, 2, 3), edge(2, 1, 4)],
};
let request_b = GoBridgeRequest {
root_iri: "root".to_owned(),
nodes: vec![
node(4, "sibling"),
node(3, "leaf"),
node(2, "child"),
node(1, "root"),
],
edges: vec![edge(2, 1, 4), edge(1, 2, 3), edge(0, 1, 2)],
};
let response_a = process_go_bridge_request_with_options(request_a, config.clone()).unwrap();
let response_b = process_go_bridge_request_with_options(request_b, config).unwrap();
assert_eq!(
sorted_nodes(response_a.nodes),
sorted_nodes(response_b.nodes)
);
assert_eq!(
sorted_segments(response_a.route_segments),
sorted_segments(response_b.route_segments)
);
}
#[test]
fn writes_debug_svg_to_configured_output_path() {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!(
"radial_sugiyama_bridge_svg_{}_{}",
std::process::id(),
unique
));
let path = dir.join("layout.svg");
let mut config = runtime_config();
config.svg_output_path = Some(path.clone());
let response = process_go_bridge_request_with_options(
GoBridgeRequest {
root_iri: "root".to_owned(),
nodes: vec![node(1, "root"), node(2, "child"), node(3, "leaf")],
edges: vec![edge(0, 1, 2), edge(1, 2, 3)],
},
config,
)
.unwrap();
assert_eq!(response.nodes.len(), 3);
let svg = fs::read_to_string(&path).unwrap();
assert!(svg.contains("<svg"));
assert!(svg.contains("<path"));
let _ = fs::remove_file(&path);
let _ = fs::remove_dir_all(&dir);
}
}

View File

@@ -0,0 +1,324 @@
use std::env;
use std::error::Error;
use std::fmt::{Display, Formatter};
use std::path::PathBuf;
use dotenvy::dotenv;
use crate::model::{LayoutConfig, RingDistribution, SvgConfig};
const INPUT_DIR_KEY: &str = "RADIAL_INPUT_DIR";
const INPUT_FILE_KEY: &str = "RADIAL_INPUT_FILE";
const ROOT_CLASS_IRI_KEY: &str = "RADIAL_ROOT_CLASS_IRI";
const OUTPUT_DIR_KEY: &str = "RADIAL_OUTPUT_DIR";
const OUTPUT_FILE_KEY: &str = "RADIAL_OUTPUT_FILE";
const SVG_SHORTEST_EDGES_KEY: &str = "RADIAL_SVG_SHORTEST_EDGES";
const SVG_SHOW_LABELS_KEY: &str = "RADIAL_SVG_SHOW_LABELS";
const MIN_RADIUS_KEY: &str = "RADIAL_MIN_RADIUS";
const LEVEL_DISTANCE_KEY: &str = "RADIAL_LEVEL_DISTANCE";
const ALIGN_POSITIVE_KEY: &str = "RADIAL_ALIGN_POSITIVE_COORDS";
const SPIRAL_QUALITY_KEY: &str = "RADIAL_SPIRAL_QUALITY";
const LEFT_BORDER_KEY: &str = "RADIAL_LEFT_BORDER";
const UPPER_BORDER_KEY: &str = "RADIAL_UPPER_BORDER";
const NODE_DISTANCE_KEY: &str = "RADIAL_NODE_DISTANCE";
const RING_DISTRIBUTION_KEY: &str = "RADIAL_RING_DISTRIBUTION";
const DEFAULT_OUTPUT_DIR: &str = "./out";
const DEFAULT_OUTPUT_FILE: &str = "layout.svg";
const DEFAULT_ROOT_CLASS_IRI: &str = "http://purl.obolibrary.org/obo/BFO_0000001";
#[derive(Debug, Clone, PartialEq)]
pub struct EnvConfig {
pub input_dir: PathBuf,
pub input_file: PathBuf,
pub root_class_iri: String,
pub output_dir: PathBuf,
pub output_file: PathBuf,
pub layout: LayoutConfig,
pub svg: SvgConfig,
}
impl EnvConfig {
pub fn from_env() -> Result<Self, EnvConfigError> {
let _ = dotenv();
Self::from_lookup(|key| env::var(key).ok())
}
pub fn input_path(&self) -> PathBuf {
self.input_dir.join(&self.input_file)
}
pub fn output_path(&self) -> PathBuf {
self.output_dir.join(&self.output_file)
}
fn from_lookup<F>(mut lookup: F) -> Result<Self, EnvConfigError>
where
F: FnMut(&str) -> Option<String>,
{
let defaults = LayoutConfig::default();
let input_dir = PathBuf::from(require_var(&mut lookup, INPUT_DIR_KEY)?);
let input_file = PathBuf::from(require_var(&mut lookup, INPUT_FILE_KEY)?);
let root_class_iri =
lookup(ROOT_CLASS_IRI_KEY).unwrap_or_else(|| DEFAULT_ROOT_CLASS_IRI.to_owned());
let output_dir =
PathBuf::from(lookup(OUTPUT_DIR_KEY).unwrap_or_else(|| DEFAULT_OUTPUT_DIR.to_owned()));
let output_file = PathBuf::from(
lookup(OUTPUT_FILE_KEY).unwrap_or_else(|| DEFAULT_OUTPUT_FILE.to_owned()),
);
let layout = LayoutConfig {
min_radius: parse_f64(&mut lookup, MIN_RADIUS_KEY, defaults.min_radius)?,
level_distance: parse_f64(&mut lookup, LEVEL_DISTANCE_KEY, defaults.level_distance)?,
align_positive_coords: parse_bool(
&mut lookup,
ALIGN_POSITIVE_KEY,
defaults.align_positive_coords,
)?,
spiral_quality: parse_usize(&mut lookup, SPIRAL_QUALITY_KEY, defaults.spiral_quality)?,
left_border: parse_f64(&mut lookup, LEFT_BORDER_KEY, defaults.left_border)?,
upper_border: parse_f64(&mut lookup, UPPER_BORDER_KEY, defaults.upper_border)?,
node_distance: parse_f64(&mut lookup, NODE_DISTANCE_KEY, defaults.node_distance)?,
ring_distribution: parse_ring_distribution(
&mut lookup,
RING_DISTRIBUTION_KEY,
defaults.ring_distribution,
)?,
};
let svg = SvgConfig {
shortest_edges: parse_bool(
&mut lookup,
SVG_SHORTEST_EDGES_KEY,
SvgConfig::default().shortest_edges,
)?,
show_labels: parse_bool(
&mut lookup,
SVG_SHOW_LABELS_KEY,
SvgConfig::default().show_labels,
)?,
};
Ok(Self {
input_dir,
input_file,
root_class_iri,
output_dir,
output_file,
layout,
svg,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EnvConfigError {
MissingVar(&'static str),
InvalidFloat { key: &'static str, value: String },
InvalidUsize { key: &'static str, value: String },
InvalidBool { key: &'static str, value: String },
InvalidRingDistribution { key: &'static str, value: String },
}
impl Display for EnvConfigError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
EnvConfigError::MissingVar(key) => write!(f, "missing required environment variable {key}"),
EnvConfigError::InvalidFloat { key, value } => {
write!(f, "environment variable {key} must be a float, got {value}")
}
EnvConfigError::InvalidUsize { key, value } => {
write!(f, "environment variable {key} must be a non-negative integer, got {value}")
}
EnvConfigError::InvalidBool { key, value } => {
write!(f, "environment variable {key} must be a boolean, got {value}")
}
EnvConfigError::InvalidRingDistribution { key, value } => write!(
f,
"environment variable {key} must be 'packed', 'distributed', or 'adaptive', got {value}"
),
}
}
}
impl Error for EnvConfigError {}
fn require_var<F>(lookup: &mut F, key: &'static str) -> Result<String, EnvConfigError>
where
F: FnMut(&str) -> Option<String>,
{
lookup(key).ok_or(EnvConfigError::MissingVar(key))
}
fn parse_f64<F>(lookup: &mut F, key: &'static str, default: f64) -> Result<f64, EnvConfigError>
where
F: FnMut(&str) -> Option<String>,
{
match lookup(key) {
Some(value) => value
.parse::<f64>()
.map_err(|_| EnvConfigError::InvalidFloat { key, value }),
None => Ok(default),
}
}
fn parse_usize<F>(
lookup: &mut F,
key: &'static str,
default: usize,
) -> Result<usize, EnvConfigError>
where
F: FnMut(&str) -> Option<String>,
{
match lookup(key) {
Some(value) => value
.parse::<usize>()
.map_err(|_| EnvConfigError::InvalidUsize { key, value }),
None => Ok(default),
}
}
fn parse_bool<F>(lookup: &mut F, key: &'static str, default: bool) -> Result<bool, EnvConfigError>
where
F: FnMut(&str) -> Option<String>,
{
match lookup(key) {
Some(value) => match value.to_ascii_lowercase().as_str() {
"true" | "1" | "yes" | "on" => Ok(true),
"false" | "0" | "no" | "off" => Ok(false),
_ => Err(EnvConfigError::InvalidBool { key, value }),
},
None => Ok(default),
}
}
fn parse_ring_distribution<F>(
lookup: &mut F,
key: &'static str,
default: RingDistribution,
) -> Result<RingDistribution, EnvConfigError>
where
F: FnMut(&str) -> Option<String>,
{
match lookup(key) {
Some(value) => match value.to_ascii_lowercase().as_str() {
"packed" => Ok(RingDistribution::Packed),
"distributed" => Ok(RingDistribution::Distributed),
"adaptive" => Ok(RingDistribution::Adaptive),
_ => Err(EnvConfigError::InvalidRingDistribution { key, value }),
},
None => Ok(default),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn config_from_map(entries: &[(&str, &str)]) -> Result<EnvConfig, EnvConfigError> {
let vars = entries
.iter()
.map(|(key, value)| ((*key).to_owned(), (*value).to_owned()))
.collect::<std::collections::HashMap<_, _>>();
EnvConfig::from_lookup(|key| vars.get(key).cloned())
}
#[test]
fn parses_env_config_and_path() {
let config = config_from_map(&[
(INPUT_DIR_KEY, "./ttl"),
(INPUT_FILE_KEY, "ontology.ttl"),
(ROOT_CLASS_IRI_KEY, "http://example.com/root"),
(OUTPUT_DIR_KEY, "./svg"),
(OUTPUT_FILE_KEY, "graph.svg"),
(SVG_SHORTEST_EDGES_KEY, "true"),
(SVG_SHOW_LABELS_KEY, "false"),
(MIN_RADIUS_KEY, "2.5"),
(LEVEL_DISTANCE_KEY, "3.0"),
(ALIGN_POSITIVE_KEY, "false"),
(SPIRAL_QUALITY_KEY, "800"),
(LEFT_BORDER_KEY, "120.0"),
(UPPER_BORDER_KEY, "140.0"),
(NODE_DISTANCE_KEY, "90.0"),
(RING_DISTRIBUTION_KEY, "adaptive"),
])
.unwrap();
assert_eq!(
config.input_path(),
PathBuf::from("./ttl").join("ontology.ttl")
);
assert_eq!(config.root_class_iri, "http://example.com/root");
assert_eq!(
config.output_path(),
PathBuf::from("./svg").join("graph.svg")
);
assert_eq!(config.layout.min_radius, 2.5);
assert_eq!(config.layout.level_distance, 3.0);
assert!(!config.layout.align_positive_coords);
assert_eq!(config.layout.spiral_quality, 800);
assert_eq!(config.layout.left_border, 120.0);
assert_eq!(config.layout.upper_border, 140.0);
assert_eq!(config.layout.node_distance, 90.0);
assert_eq!(config.layout.ring_distribution, RingDistribution::Adaptive);
assert!(config.svg.shortest_edges);
assert!(!config.svg.show_labels);
}
#[test]
fn missing_input_file_is_reported() {
let error = config_from_map(&[(INPUT_DIR_KEY, "./ttl")]).unwrap_err();
assert_eq!(error, EnvConfigError::MissingVar(INPUT_FILE_KEY));
}
#[test]
fn invalid_boolean_is_reported() {
let error = config_from_map(&[
(INPUT_DIR_KEY, "./ttl"),
(INPUT_FILE_KEY, "ontology.ttl"),
(ALIGN_POSITIVE_KEY, "maybe"),
])
.unwrap_err();
assert_eq!(
error,
EnvConfigError::InvalidBool {
key: ALIGN_POSITIVE_KEY,
value: "maybe".to_owned(),
}
);
}
#[test]
fn uses_default_output_location_when_not_provided() {
let config =
config_from_map(&[(INPUT_DIR_KEY, "./ttl"), (INPUT_FILE_KEY, "ontology.ttl")]).unwrap();
assert_eq!(
config.root_class_iri,
"http://purl.obolibrary.org/obo/BFO_0000001"
);
assert_eq!(
config.output_path(),
PathBuf::from("./out").join("layout.svg")
);
assert!(!config.svg.shortest_edges);
assert!(config.svg.show_labels);
assert_eq!(config.layout.ring_distribution, RingDistribution::Packed);
}
#[test]
fn invalid_ring_distribution_is_reported() {
let error = config_from_map(&[
(INPUT_DIR_KEY, "./ttl"),
(INPUT_FILE_KEY, "ontology.ttl"),
(RING_DISTRIBUTION_KEY, "arc"),
])
.unwrap_err();
assert_eq!(
error,
EnvConfigError::InvalidRingDistribution {
key: RING_DISTRIBUTION_KEY,
value: "arc".to_owned(),
}
);
}
}

View File

@@ -0,0 +1,70 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LayoutError {
InvalidNodeIndex {
edge_index: usize,
node_index: usize,
node_count: usize,
},
SelfLoop {
edge_index: usize,
node: usize,
},
DuplicateEdge {
edge_index: usize,
source: usize,
target: usize,
},
CycleDetected,
InvalidHierarchyEdge {
edge_index: usize,
source: usize,
target: usize,
source_level: usize,
target_level: usize,
},
}
impl Display for LayoutError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
LayoutError::InvalidNodeIndex {
edge_index,
node_index,
node_count,
} => write!(
f,
"edge {} references node {} but graph only has {} nodes",
edge_index, node_index, node_count
),
LayoutError::SelfLoop { edge_index, node } => {
write!(f, "edge {} is a self-loop on node {}", edge_index, node)
}
LayoutError::DuplicateEdge {
edge_index,
source,
target,
} => write!(
f,
"edge {} duplicates existing directed edge {} -> {}",
edge_index, source, target
),
LayoutError::CycleDetected => write!(f, "graph must be a directed acyclic graph"),
LayoutError::InvalidHierarchyEdge {
edge_index,
source,
target,
source_level,
target_level,
} => write!(
f,
"edge {} ({} -> {}) violates hierarchy levels {} -> {}",
edge_index, source, target, source_level, target_level
),
}
}
}
impl Error for LayoutError {}

View File

@@ -0,0 +1,159 @@
use std::collections::{HashMap, HashSet, VecDeque};
use std::error::Error;
use std::fmt::{Display, Formatter};
use crate::model::{Edge, Graph};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GraphFilterError {
RootNotFound { root_iri: String },
NoDescendants { root_iri: String },
}
impl Display for GraphFilterError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
GraphFilterError::RootNotFound { root_iri } => {
write!(
f,
"root class IRI {root_iri} was not found in the imported graph"
)
}
GraphFilterError::NoDescendants { root_iri } => {
write!(
f,
"root class IRI {root_iri} has no subclass descendants in the imported graph"
)
}
}
}
}
impl Error for GraphFilterError {}
pub fn filter_graph_to_descendants(
graph: &Graph,
root_iri: &str,
) -> Result<Graph, GraphFilterError> {
let Some(root_index) = graph
.nodes
.iter()
.position(|node| node.label.as_deref() == Some(root_iri))
else {
return Err(GraphFilterError::RootNotFound {
root_iri: root_iri.to_owned(),
});
};
let mut adjacency = vec![Vec::new(); graph.nodes.len()];
for edge in &graph.edges {
adjacency[edge.source].push(edge.target);
}
let mut visited = HashSet::from([root_index]);
let mut queue = VecDeque::from([root_index]);
while let Some(node) = queue.pop_front() {
for &child in &adjacency[node] {
if visited.insert(child) {
queue.push_back(child);
}
}
}
if visited.len() <= 1 {
return Err(GraphFilterError::NoDescendants {
root_iri: root_iri.to_owned(),
});
}
let mut reindex = HashMap::new();
let mut nodes = Vec::new();
for (old_index, node) in graph.nodes.iter().enumerate() {
if visited.contains(&old_index) {
let new_index = nodes.len();
reindex.insert(old_index, new_index);
nodes.push(node.clone());
}
}
let mut edges = Vec::new();
for edge in &graph.edges {
if visited.contains(&edge.source) && visited.contains(&edge.target) {
edges.push(Edge::new(reindex[&edge.source], reindex[&edge.target]));
}
}
Ok(Graph::new(nodes, edges))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::Node;
fn node(label: &str) -> Node {
Node {
label: Some(label.to_owned()),
..Node::default()
}
}
#[test]
fn keeps_root_and_all_descendants() {
let graph = Graph::new(
vec![
node("root"),
node("child"),
node("grandchild"),
node("other"),
node("other_child"),
],
vec![Edge::new(0, 1), Edge::new(1, 2), Edge::new(3, 4)],
);
let filtered = filter_graph_to_descendants(&graph, "root").unwrap();
assert_eq!(filtered.nodes.len(), 3);
assert_eq!(
filtered
.nodes
.iter()
.map(|node| node.label.clone())
.collect::<Vec<_>>(),
vec![
Some("root".to_owned()),
Some("child".to_owned()),
Some("grandchild".to_owned()),
]
);
assert_eq!(filtered.edges, vec![Edge::new(0, 1), Edge::new(1, 2)]);
}
#[test]
fn returns_error_when_root_is_missing() {
let graph = Graph::new(vec![node("other")], vec![]);
let error = filter_graph_to_descendants(&graph, "root").unwrap_err();
assert_eq!(
error,
GraphFilterError::RootNotFound {
root_iri: "root".to_owned(),
}
);
}
#[test]
fn returns_error_when_root_has_no_descendants() {
let graph = Graph::new(vec![node("root"), node("other")], vec![Edge::new(1, 0)]);
let error = filter_graph_to_descendants(&graph, "root").unwrap_err();
assert_eq!(
error,
GraphFilterError::NoDescendants {
root_iri: "root".to_owned(),
}
);
}
}

View File

@@ -0,0 +1,88 @@
use std::collections::{HashSet, VecDeque};
use crate::error::LayoutError;
use crate::model::Graph;
pub fn compute_hierarchy_levels(graph: &Graph) -> Result<Vec<usize>, LayoutError> {
validate_simple_dag(graph)?;
let node_count = graph.nodes.len();
if node_count == 0 {
return Ok(Vec::new());
}
let mut indegree = vec![0usize; node_count];
let mut outgoing = vec![Vec::new(); node_count];
for edge in &graph.edges {
indegree[edge.target] += 1;
outgoing[edge.source].push(edge.target);
}
let mut queue = VecDeque::new();
for (node_index, degree) in indegree.iter().enumerate() {
if *degree == 0 {
queue.push_back(node_index);
}
}
let mut levels = vec![0usize; node_count];
let mut visited = 0usize;
while let Some(node) = queue.pop_front() {
visited += 1;
let next_level = levels[node] + 1;
for &child in &outgoing[node] {
if levels[child] < next_level {
levels[child] = next_level;
}
indegree[child] -= 1;
if indegree[child] == 0 {
queue.push_back(child);
}
}
}
if visited != node_count {
return Err(LayoutError::CycleDetected);
}
Ok(levels)
}
pub(crate) fn validate_simple_dag(graph: &Graph) -> Result<(), LayoutError> {
let node_count = graph.nodes.len();
let mut seen_edges = HashSet::new();
for (edge_index, edge) in graph.edges.iter().enumerate() {
if edge.source >= node_count {
return Err(LayoutError::InvalidNodeIndex {
edge_index,
node_index: edge.source,
node_count,
});
}
if edge.target >= node_count {
return Err(LayoutError::InvalidNodeIndex {
edge_index,
node_index: edge.target,
node_count,
});
}
if edge.source == edge.target {
return Err(LayoutError::SelfLoop {
edge_index,
node: edge.source,
});
}
if !seen_edges.insert((edge.source, edge.target)) {
return Err(LayoutError::DuplicateEdge {
edge_index,
source: edge.source,
target: edge.target,
});
}
}
Ok(())
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,42 @@
//! Hierarchical radial Sugiyama layout for directed acyclic graphs.
//!
//! ```
//! use radial_sugiyama::{layout_radial_hierarchy, Edge, Graph, LayoutConfig, Node};
//!
//! let mut graph = Graph::new(
//! vec![Node::default(), Node::default(), Node::default()],
//! vec![Edge::new(0, 1), Edge::new(1, 2)],
//! );
//!
//! layout_radial_hierarchy(&mut graph, LayoutConfig::default()).unwrap();
//! assert!(graph.nodes.iter().all(|node| node.x.is_finite() && node.y.is_finite()));
//! ```
mod bridge;
mod env_config;
mod error;
mod filter;
mod layering;
mod layout;
mod model;
mod svg_export;
mod ttl;
pub use bridge::{
process_go_bridge_request, process_go_bridge_request_with_options, BridgeError,
BridgeRuntimeConfig, GoBridgeEdge, GoBridgeNode, GoBridgePoint, GoBridgeRequest,
GoBridgeResponse, GoBridgeRouteSegment, GoBridgeRoutedNode,
};
pub use env_config::{EnvConfig, EnvConfigError};
pub use error::LayoutError;
pub use filter::{filter_graph_to_descendants, GraphFilterError};
pub use layering::compute_hierarchy_levels;
pub use layout::{layout_radial_hierarchy, layout_radial_hierarchy_with_artifacts};
pub use model::{
Edge, EdgeRoute, EdgeRouteKind, Graph, LayoutArtifacts, LayoutConfig, Node, Point,
RingDistribution, RoutedNode, SvgConfig,
};
pub use svg_export::{
render_svg_string, render_svg_string_with_options, write_svg_path, write_svg_path_with_options,
SvgExportError,
};
pub use ttl::{graph_from_ttl_path, graph_from_ttl_reader, TtlImportError};

View File

@@ -0,0 +1,29 @@
use std::error::Error;
use std::fs::create_dir_all;
use radial_sugiyama::{
filter_graph_to_descendants, graph_from_ttl_path, layout_radial_hierarchy_with_artifacts,
write_svg_path_with_options, EnvConfig,
};
fn main() -> Result<(), Box<dyn Error>> {
let config = EnvConfig::from_env()?;
let input_path = config.input_path();
let output_path = config.output_path();
let imported_graph = graph_from_ttl_path(&input_path)?;
let mut graph = filter_graph_to_descendants(&imported_graph, &config.root_class_iri)?;
let artifacts = layout_radial_hierarchy_with_artifacts(&mut graph, config.layout)?;
if let Some(parent) = output_path.parent() {
create_dir_all(parent)?;
}
write_svg_path_with_options(&output_path, &graph, &artifacts, config.layout, config.svg)?;
println!("input={}", input_path.display());
println!("root={}", config.root_class_iri);
println!("output={}", output_path.display());
println!("nodes={}", graph.nodes.len());
println!("edges={}", graph.edges.len());
println!("routes={}", artifacts.edge_routes.len());
Ok(())
}

View File

@@ -0,0 +1,133 @@
#[derive(Debug, Clone, PartialEq)]
pub struct Graph {
pub nodes: Vec<Node>,
pub edges: Vec<Edge>,
}
impl Graph {
pub fn new(nodes: Vec<Node>, edges: Vec<Edge>) -> Self {
Self { nodes, edges }
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Node {
pub label: Option<String>,
pub x: f64,
pub y: f64,
}
impl Default for Node {
fn default() -> Self {
Self {
label: None,
x: 0.0,
y: 0.0,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Edge {
pub source: usize,
pub target: usize,
}
impl Edge {
pub fn new(source: usize, target: usize) -> Self {
Self { source, target }
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Point {
pub x: f64,
pub y: f64,
}
impl Point {
pub fn new(x: f64, y: f64) -> Self {
Self { x, y }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EdgeRouteKind {
Straight,
Spiral,
IntraLevel,
}
#[derive(Debug, Clone, PartialEq)]
pub struct EdgeRoute {
pub original_edge_index: usize,
pub source: usize,
pub target: usize,
pub kind: EdgeRouteKind,
pub points: Vec<Point>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct RoutedNode {
pub original_index: Option<usize>,
pub level: usize,
pub point: Point,
}
#[derive(Debug, Clone, PartialEq)]
pub struct LayoutArtifacts {
pub node_levels: Vec<usize>,
pub edge_offsets: Vec<i32>,
pub edge_routes: Vec<EdgeRoute>,
pub routed_nodes: Vec<RoutedNode>,
pub center: Point,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SvgConfig {
pub shortest_edges: bool,
pub show_labels: bool,
}
impl Default for SvgConfig {
fn default() -> Self {
Self {
shortest_edges: false,
show_labels: true,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RingDistribution {
Packed,
Distributed,
Adaptive,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LayoutConfig {
pub min_radius: f64,
pub level_distance: f64,
pub align_positive_coords: bool,
pub spiral_quality: usize,
pub left_border: f64,
pub upper_border: f64,
pub node_distance: f64,
pub ring_distribution: RingDistribution,
}
impl Default for LayoutConfig {
fn default() -> Self {
Self {
min_radius: 1.0,
level_distance: 1.0,
align_positive_coords: true,
spiral_quality: 500,
left_border: 80.0,
upper_border: 80.0,
node_distance: 80.0,
ring_distribution: RingDistribution::Packed,
}
}
}

View File

@@ -0,0 +1,469 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
use std::path::Path;
use svg::node::element::path::Data;
use svg::node::element::{Circle, Path as SvgPathElement, Rectangle, Text as SvgText};
use svg::Document;
use crate::model::{Graph, LayoutArtifacts, LayoutConfig, Point, SvgConfig};
const BACKGROUND_COLOR: &str = "#ffffff";
const RING_COLOR: &str = "#d9d9d9";
const EDGE_COLOR: &str = "#5c6773";
const NODE_FILL_COLOR: &str = "#4f81bd";
const NODE_STROKE_COLOR: &str = "#355c8a";
const LABEL_COLOR: &str = "#111111";
const NODE_RADIUS: f64 = 6.0;
const LABEL_FONT_SIZE: usize = 9;
const LABEL_X_OFFSET: f64 = NODE_RADIUS + 4.0;
const LABEL_Y_OFFSET: f64 = NODE_RADIUS + 2.0;
const LABEL_WIDTH_FACTOR: f64 = 0.56;
const EDGE_STROKE_WIDTH: f64 = 1.5;
const RING_STROKE_WIDTH: f64 = 1.0;
const VIEWBOX_HORIZONTAL_MARGIN: f64 = 72.0;
const VIEWBOX_VERTICAL_MARGIN: f64 = 36.0;
#[derive(Debug)]
pub enum SvgExportError {
Io(std::io::Error),
}
impl Display for SvgExportError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
SvgExportError::Io(error) => write!(f, "failed to write SVG output: {error}"),
}
}
}
impl Error for SvgExportError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
SvgExportError::Io(error) => Some(error),
}
}
}
impl From<std::io::Error> for SvgExportError {
fn from(error: std::io::Error) -> Self {
Self::Io(error)
}
}
pub fn write_svg_path(
path: impl AsRef<Path>,
graph: &Graph,
artifacts: &LayoutArtifacts,
layout: LayoutConfig,
) -> Result<(), SvgExportError> {
write_svg_path_with_options(path, graph, artifacts, layout, SvgConfig::default())
}
pub fn render_svg_string(
graph: &Graph,
artifacts: &LayoutArtifacts,
layout: LayoutConfig,
) -> String {
render_svg_string_with_options(graph, artifacts, layout, SvgConfig::default())
}
pub fn write_svg_path_with_options(
path: impl AsRef<Path>,
graph: &Graph,
artifacts: &LayoutArtifacts,
layout: LayoutConfig,
svg_config: SvgConfig,
) -> Result<(), SvgExportError> {
svg::save(path, &build_document(graph, artifacts, layout, svg_config)).map_err(Into::into)
}
pub fn render_svg_string_with_options(
graph: &Graph,
artifacts: &LayoutArtifacts,
layout: LayoutConfig,
svg_config: SvgConfig,
) -> String {
build_document(graph, artifacts, layout, svg_config).to_string()
}
fn build_document(
graph: &Graph,
artifacts: &LayoutArtifacts,
layout: LayoutConfig,
svg_config: SvgConfig,
) -> Document {
let bounds = compute_bounds(graph, artifacts, layout, svg_config);
let width = (bounds.max_x - bounds.min_x).max(1.0);
let height = (bounds.max_y - bounds.min_y).max(1.0);
let mut document = Document::new()
.set("viewBox", (bounds.min_x, bounds.min_y, width, height))
.set("width", width)
.set("height", height);
document = document.add(
Rectangle::new()
.set("x", bounds.min_x)
.set("y", bounds.min_y)
.set("width", width)
.set("height", height)
.set("fill", BACKGROUND_COLOR),
);
for radius in ring_radii(artifacts, layout) {
document = document.add(
Circle::new()
.set("cx", artifacts.center.x)
.set("cy", artifacts.center.y)
.set("r", radius)
.set("fill", "none")
.set("stroke", RING_COLOR)
.set("stroke-width", RING_STROKE_WIDTH),
);
}
for data in edge_paths(graph, artifacts, svg_config) {
document = document.add(
SvgPathElement::new()
.set("fill", "none")
.set("stroke", EDGE_COLOR)
.set("stroke-width", EDGE_STROKE_WIDTH)
.set("stroke-linecap", "round")
.set("stroke-linejoin", "round")
.set("d", data),
);
}
for node in &graph.nodes {
document = document.add(
Circle::new()
.set("cx", node.x)
.set("cy", node.y)
.set("r", NODE_RADIUS)
.set("fill", NODE_FILL_COLOR)
.set("stroke", NODE_STROKE_COLOR)
.set("stroke-width", 1.0),
);
}
if svg_config.show_labels {
for node in &graph.nodes {
if let Some(label) = &node.label {
document = document.add(
SvgText::new(label.clone())
.set("x", node.x + LABEL_X_OFFSET)
.set("y", node.y - LABEL_Y_OFFSET)
.set("fill", LABEL_COLOR)
.set("font-size", LABEL_FONT_SIZE)
.set("font-family", "Arial, Helvetica, sans-serif"),
);
}
}
}
document
}
fn edge_paths(graph: &Graph, artifacts: &LayoutArtifacts, svg_config: SvgConfig) -> Vec<Data> {
if svg_config.shortest_edges {
graph
.edges
.iter()
.map(|edge| {
let source = &graph.nodes[edge.source];
let target = &graph.nodes[edge.target];
Data::new()
.move_to((source.x, source.y))
.line_to((target.x, target.y))
})
.collect()
} else {
artifacts
.edge_routes
.iter()
.filter_map(|route| {
if route.points.len() < 2 {
return None;
}
let mut data = Data::new().move_to((route.points[0].x, route.points[0].y));
for point in route.points.iter().skip(1) {
data = data.line_to((point.x, point.y));
}
Some(data)
})
.collect()
}
}
fn ring_radii(artifacts: &LayoutArtifacts, layout: LayoutConfig) -> Vec<f64> {
let Some(max_level) = artifacts.node_levels.iter().copied().max() else {
return Vec::new();
};
let center_only_level = artifacts
.node_levels
.iter()
.filter(|&&level| level == 0)
.count()
== 1;
let start_level = if center_only_level { 1 } else { 0 };
if start_level > max_level {
return Vec::new();
}
(start_level..=max_level)
.map(|level| {
let radial_units = layout.min_radius
+ (level.saturating_sub(start_level) as f64 * layout.level_distance);
radial_units * layout.node_distance
})
.collect()
}
#[derive(Debug, Clone, Copy)]
struct Bounds {
min_x: f64,
min_y: f64,
max_x: f64,
max_y: f64,
}
impl Bounds {
fn around(point: Point) -> Self {
Self {
min_x: point.x,
min_y: point.y,
max_x: point.x,
max_y: point.y,
}
}
fn include_point(&mut self, point: Point) {
self.min_x = self.min_x.min(point.x);
self.min_y = self.min_y.min(point.y);
self.max_x = self.max_x.max(point.x);
self.max_y = self.max_y.max(point.y);
}
fn include_radius(&mut self, center: Point, radius: f64) {
self.include_point(Point::new(center.x - radius, center.y - radius));
self.include_point(Point::new(center.x + radius, center.y + radius));
}
fn expand(&mut self, horizontal_margin: f64, vertical_margin: f64) {
self.min_x -= horizontal_margin;
self.min_y -= vertical_margin;
self.max_x += horizontal_margin;
self.max_y += vertical_margin;
}
}
fn compute_bounds(
graph: &Graph,
artifacts: &LayoutArtifacts,
layout: LayoutConfig,
svg_config: SvgConfig,
) -> Bounds {
let mut bounds = Bounds::around(artifacts.center);
for radius in ring_radii(artifacts, layout) {
bounds.include_radius(artifacts.center, radius);
}
for node in &graph.nodes {
bounds.include_radius(Point::new(node.x, node.y), NODE_RADIUS);
if svg_config.show_labels {
if let Some(label) = &node.label {
include_label_bounds(&mut bounds, node, label);
}
}
}
for route in &artifacts.edge_routes {
for &point in &route.points {
bounds.include_point(point);
}
}
bounds.expand(VIEWBOX_HORIZONTAL_MARGIN, VIEWBOX_VERTICAL_MARGIN);
bounds
}
fn include_label_bounds(bounds: &mut Bounds, node: &crate::model::Node, label: &str) {
let start_x = node.x + LABEL_X_OFFSET;
let baseline_y = node.y - LABEL_Y_OFFSET;
let width = estimate_label_width(label);
let ascent = LABEL_FONT_SIZE as f64;
let descent = LABEL_FONT_SIZE as f64 * 0.3;
bounds.include_point(Point::new(start_x, baseline_y - ascent));
bounds.include_point(Point::new(start_x + width, baseline_y + descent));
}
fn estimate_label_width(label: &str) -> f64 {
label.chars().count() as f64 * LABEL_FONT_SIZE as f64 * LABEL_WIDTH_FACTOR
}
#[cfg(test)]
mod tests {
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
use super::*;
use crate::{graph_from_ttl_reader, layout_radial_hierarchy_with_artifacts, Edge, Graph, Node};
fn simple_graph() -> (Graph, LayoutArtifacts, LayoutConfig) {
let mut graph = Graph::new(
vec![
Node {
label: Some("Root".to_owned()),
..Node::default()
},
Node {
label: Some("Child".to_owned()),
..Node::default()
},
Node {
label: Some("Leaf".to_owned()),
..Node::default()
},
],
vec![Edge::new(0, 1), Edge::new(1, 2)],
);
let layout = LayoutConfig::default();
let artifacts = layout_radial_hierarchy_with_artifacts(&mut graph, layout).unwrap();
(graph, artifacts, layout)
}
#[test]
fn render_svg_contains_svg_root_and_paths() {
let (graph, artifacts, layout) = simple_graph();
let svg = render_svg_string(&graph, &artifacts, layout);
assert!(svg.contains("<svg"));
assert!(svg.contains("<path"));
}
#[test]
fn render_svg_includes_labels_and_rings() {
let (graph, artifacts, layout) = simple_graph();
let svg = render_svg_string(&graph, &artifacts, layout);
assert!(svg.contains("Root"));
assert!(svg.contains("Child"));
assert!(svg.contains("<circle"));
assert!(svg.contains("font-size=\"9\""));
}
#[test]
fn single_root_layout_skips_level_zero_ring() {
let (graph, artifacts, layout) = simple_graph();
let svg = render_svg_string(&graph, &artifacts, layout);
let ring_count = svg.matches("stroke=\"#d9d9d9\"").count();
assert_eq!(ring_count, 2);
}
#[test]
fn writes_svg_file_to_disk() {
let (graph, artifacts, layout) = simple_graph();
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = std::env::temp_dir().join(format!(
"radial_sugiyama_svg_test_{}_{}.svg",
std::process::id(),
unique
));
write_svg_path(&path, &graph, &artifacts, layout).unwrap();
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("<svg"));
let _ = fs::remove_file(path);
}
#[test]
fn turtle_to_svg_pipeline_contains_labels() {
let ttl = "@prefix ex: <http://example.com/> .\n@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .\nex:A rdfs:subClassOf ex:B .\nex:B rdfs:subClassOf ex:C .\n";
let mut graph = graph_from_ttl_reader(ttl.as_bytes()).unwrap();
let layout = LayoutConfig::default();
let artifacts = layout_radial_hierarchy_with_artifacts(&mut graph, layout).unwrap();
let svg = render_svg_string(&graph, &artifacts, layout);
assert!(svg.contains("http://example.com/A"));
assert!(svg.contains("http://example.com/B"));
}
#[test]
fn shortest_edge_option_draws_original_edges_as_direct_segments() {
let mut graph = Graph::new(
vec![Node::default(), Node::default(), Node::default()],
vec![Edge::new(0, 1), Edge::new(1, 2), Edge::new(0, 2)],
);
let layout = LayoutConfig::default();
let artifacts = layout_radial_hierarchy_with_artifacts(&mut graph, layout).unwrap();
let svg = render_svg_string_with_options(
&graph,
&artifacts,
layout,
SvgConfig {
shortest_edges: true,
show_labels: true,
},
);
assert_eq!(svg.matches("<path").count(), graph.edges.len());
}
#[test]
fn bounds_expand_to_fit_long_labels() {
let graph = Graph::new(
vec![Node {
label: Some("http://example.com/very/long/uri/that/should/fit".to_owned()),
x: 100.0,
y: 100.0,
}],
vec![],
);
let artifacts = LayoutArtifacts {
node_levels: vec![0],
edge_offsets: vec![],
edge_routes: vec![],
routed_nodes: vec![],
center: Point::new(100.0, 100.0),
};
let bounds = compute_bounds(
&graph,
&artifacts,
LayoutConfig::default(),
SvgConfig::default(),
);
assert!(bounds.max_x > 300.0);
assert!(bounds.max_y > 100.0);
}
#[test]
fn render_svg_omits_labels_when_disabled() {
let (graph, artifacts, layout) = simple_graph();
let svg = render_svg_string_with_options(
&graph,
&artifacts,
layout,
SvgConfig {
shortest_edges: false,
show_labels: false,
},
);
assert!(!svg.contains("Root"));
assert!(!svg.contains("<text"));
}
}

200
radial_sugiyama/src/ttl.rs Normal file
View File

@@ -0,0 +1,200 @@
use std::collections::{HashMap, HashSet};
use std::error::Error;
use std::fmt::{Display, Formatter};
use std::fs::File;
use std::io::{BufReader, Read};
use std::path::Path;
use oxrdf::vocab::rdfs;
use oxrdf::{NamedOrBlankNode, Term};
use oxttl::{TurtleParseError, TurtleParser};
use crate::model::{Edge, Graph, Node};
#[derive(Debug)]
pub enum TtlImportError {
Io(std::io::Error),
Parse(TurtleParseError),
NoSubclassTriples,
}
impl Display for TtlImportError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
TtlImportError::Io(error) => write!(f, "failed to read Turtle input: {error}"),
TtlImportError::Parse(error) => write!(f, "failed to parse Turtle input: {error}"),
TtlImportError::NoSubclassTriples => {
write!(
f,
"no usable rdfs:subClassOf triples were found in the Turtle input"
)
}
}
}
}
impl Error for TtlImportError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
TtlImportError::Io(error) => Some(error),
TtlImportError::Parse(error) => Some(error),
TtlImportError::NoSubclassTriples => None,
}
}
}
impl From<std::io::Error> for TtlImportError {
fn from(error: std::io::Error) -> Self {
Self::Io(error)
}
}
impl From<TurtleParseError> for TtlImportError {
fn from(error: TurtleParseError) -> Self {
Self::Parse(error)
}
}
pub fn graph_from_ttl_reader<R: Read>(reader: R) -> Result<Graph, TtlImportError> {
let mut nodes = Vec::new();
let mut node_indices = HashMap::new();
let mut edges = Vec::new();
let mut seen_edges = HashSet::new();
for triple in TurtleParser::new().for_reader(reader) {
let triple = triple?;
if triple.predicate.as_ref() != rdfs::SUB_CLASS_OF {
continue;
}
let NamedOrBlankNode::NamedNode(subject) = triple.subject else {
continue;
};
let Term::NamedNode(object) = triple.object else {
continue;
};
let subclass = get_or_insert_node(&mut nodes, &mut node_indices, subject.as_str());
let superclass = get_or_insert_node(&mut nodes, &mut node_indices, object.as_str());
if seen_edges.insert((superclass, subclass)) {
edges.push(Edge::new(superclass, subclass));
}
}
if edges.is_empty() {
return Err(TtlImportError::NoSubclassTriples);
}
Ok(Graph::new(nodes, edges))
}
pub fn graph_from_ttl_path(path: impl AsRef<Path>) -> Result<Graph, TtlImportError> {
let file = File::open(path)?;
graph_from_ttl_reader(BufReader::new(file))
}
fn get_or_insert_node(
nodes: &mut Vec<Node>,
node_indices: &mut HashMap<String, usize>,
iri: &str,
) -> usize {
if let Some(&index) = node_indices.get(iri) {
return index;
}
let index = nodes.len();
nodes.push(Node {
label: Some(iri.to_owned()),
..Node::default()
});
node_indices.insert(iri.to_owned(), index);
index
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{layout_radial_hierarchy, LayoutConfig};
const TTL_PREFIXES: &str = "@prefix ex: <http://example.com/> .\n@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .\n@prefix schema: <http://schema.org/> .\n";
#[test]
fn imports_only_subclass_triples() {
let ttl = format!(
"{TTL_PREFIXES}ex:A rdfs:subClassOf ex:B .\nex:A schema:name \"Alpha\" .\nex:B schema:name \"Beta\" .\n"
);
let graph = graph_from_ttl_reader(ttl.as_bytes()).unwrap();
assert_eq!(graph.nodes.len(), 2);
assert_eq!(graph.edges, vec![Edge::new(1, 0)]);
assert_eq!(
graph.nodes[0].label.as_deref(),
Some("http://example.com/A")
);
assert_eq!(
graph.nodes[1].label.as_deref(),
Some("http://example.com/B")
);
}
#[test]
fn deduplicates_repeated_subclass_triples() {
let ttl =
format!("{TTL_PREFIXES}ex:A rdfs:subClassOf ex:B .\nex:A rdfs:subClassOf ex:B .\n");
let graph = graph_from_ttl_reader(ttl.as_bytes()).unwrap();
assert_eq!(graph.edges, vec![Edge::new(1, 0)]);
}
#[test]
fn ignores_blank_node_and_literal_targets() {
let ttl = format!(
"{TTL_PREFIXES}ex:A rdfs:subClassOf [ a ex:Anonymous ] .\nex:B rdfs:subClassOf \"Literal\" .\nex:C rdfs:subClassOf ex:D .\n"
);
let graph = graph_from_ttl_reader(ttl.as_bytes()).unwrap();
assert_eq!(graph.nodes.len(), 2);
assert_eq!(graph.edges, vec![Edge::new(1, 0)]);
assert_eq!(
graph.nodes[0].label.as_deref(),
Some("http://example.com/C")
);
assert_eq!(
graph.nodes[1].label.as_deref(),
Some("http://example.com/D")
);
}
#[test]
fn imports_can_flow_into_layout() {
let ttl =
format!("{TTL_PREFIXES}ex:A rdfs:subClassOf ex:B .\nex:B rdfs:subClassOf ex:C .\n");
let mut graph = graph_from_ttl_reader(ttl.as_bytes()).unwrap();
layout_radial_hierarchy(&mut graph, LayoutConfig::default()).unwrap();
assert!(graph
.nodes
.iter()
.all(|node| node.x.is_finite() && node.y.is_finite()));
}
#[test]
fn returns_clear_error_for_invalid_turtle() {
let ttl = "@prefix ex: <http://example.com/> .\nex:A rdfs:subClassOf .\n";
let error = graph_from_ttl_reader(ttl.as_bytes()).unwrap_err();
assert!(matches!(error, TtlImportError::Parse(_)));
}
#[test]
fn returns_clear_error_when_no_subclass_triples_exist() {
let ttl = format!("{TTL_PREFIXES}ex:A schema:name \"Alpha\" .\n");
let error = graph_from_ttl_reader(ttl.as_bytes()).unwrap_err();
assert!(matches!(error, TtlImportError::NoSubclassTriples));
}
}