radial sugiyama positioning integration
This commit is contained in:
38
radial_sugiyama/src/bin/radial_sugiyama_go_bridge.rs
Normal file
38
radial_sugiyama/src/bin/radial_sugiyama_go_bridge.rs
Normal 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(())
|
||||
}
|
||||
784
radial_sugiyama/src/bridge.rs
Normal file
784
radial_sugiyama/src/bridge.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
324
radial_sugiyama/src/env_config.rs
Normal file
324
radial_sugiyama/src/env_config.rs
Normal 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(),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
70
radial_sugiyama/src/error.rs
Normal file
70
radial_sugiyama/src/error.rs
Normal 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 {}
|
||||
159
radial_sugiyama/src/filter.rs
Normal file
159
radial_sugiyama/src/filter.rs
Normal 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(),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
88
radial_sugiyama/src/layering.rs
Normal file
88
radial_sugiyama/src/layering.rs
Normal 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(())
|
||||
}
|
||||
2888
radial_sugiyama/src/layout.rs
Normal file
2888
radial_sugiyama/src/layout.rs
Normal file
File diff suppressed because it is too large
Load Diff
42
radial_sugiyama/src/lib.rs
Normal file
42
radial_sugiyama/src/lib.rs
Normal 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};
|
||||
29
radial_sugiyama/src/main.rs
Normal file
29
radial_sugiyama/src/main.rs
Normal 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(())
|
||||
}
|
||||
133
radial_sugiyama/src/model.rs
Normal file
133
radial_sugiyama/src/model.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
469
radial_sugiyama/src/svg_export.rs
Normal file
469
radial_sugiyama/src/svg_export.rs
Normal 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
200
radial_sugiyama/src/ttl.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user