use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Number, Value};
use crate::errors::RenderError;
use crate::graph::attachment::EdgePort;
use crate::graph::geometry::{GraphGeometry, PositionedNode, RoutedGraphGeometry};
use crate::graph::projection::{GridProjection, OverrideSubgraphProjection};
use crate::graph::routing::{EdgeRouting, route_graph_geometry};
use crate::graph::style::NodeStyle;
use crate::graph::{Arrow, Direction, GeometryLevel, Graph, Shape, Stroke};
use crate::simplification::PathSimplification;
pub const CORE_PROFILE: &str = "mmds-core-v1";
pub const SVG_PROFILE: &str = "mmdflux-svg-v1";
pub const TEXT_PROFILE: &str = "mmdflux-text-v1";
pub const NODE_STYLE_PROFILE: &str = "mmdflux-node-style-v1";
pub const TEXT_EXTENSION_NAMESPACE: &str = "org.mmdflux.render.text.v1";
pub const NODE_STYLE_EXTENSION_NAMESPACE: &str = "org.mmdflux.node-style.v1";
pub const SUPPORTED_PROFILES: &[&str] =
&[CORE_PROFILE, SVG_PROFILE, TEXT_PROFILE, NODE_STYLE_PROFILE];
#[cfg(test)]
pub(crate) fn to_layout(diagram: &Graph, geometry: &GraphGeometry) -> String {
to_layout_typed("flowchart", diagram, geometry)
}
#[cfg(test)]
pub(crate) fn to_layout_typed(
diagram_type: &str,
diagram: &Graph,
geometry: &GraphGeometry,
) -> String {
render_output(
diagram_type,
diagram,
geometry,
None,
PathSimplification::None,
None,
)
}
#[cfg(test)]
pub(crate) fn to_routed(
diagram: &Graph,
geometry: &GraphGeometry,
routed: &RoutedGraphGeometry,
) -> String {
to_routed_typed("flowchart", diagram, geometry, routed)
}
#[cfg(test)]
pub(crate) fn to_routed_typed(
diagram_type: &str,
diagram: &Graph,
geometry: &GraphGeometry,
routed: &RoutedGraphGeometry,
) -> String {
render_output(
diagram_type,
diagram,
geometry,
Some(routed),
PathSimplification::None,
None,
)
}
#[cfg(test)]
pub(crate) fn to_json(
diagram: &Graph,
geometry: &GraphGeometry,
routed: Option<&RoutedGraphGeometry>,
level: GeometryLevel,
path_simplification: PathSimplification,
engine_id: Option<&str>,
) -> Result<String, RenderError> {
to_json_typed(
"flowchart",
diagram,
geometry,
routed,
level,
path_simplification,
engine_id,
)
}
pub(crate) fn to_json_typed(
diagram_type: &str,
diagram: &Graph,
geometry: &GraphGeometry,
routed: Option<&RoutedGraphGeometry>,
level: GeometryLevel,
path_simplification: PathSimplification,
engine_id: Option<&str>,
) -> Result<String, RenderError> {
match level {
GeometryLevel::Layout => Ok(render_output(
diagram_type,
diagram,
geometry,
None,
path_simplification,
engine_id,
)),
GeometryLevel::Routed => routed
.ok_or_else(|| RenderError {
message: "routed MMDS output requested but routed geometry was not provided"
.to_string(),
})
.map(|routed| {
render_output(
diagram_type,
diagram,
geometry,
Some(routed),
path_simplification,
engine_id,
)
}),
}
}
pub fn to_json_typed_with_routing(
diagram_type: &str,
diagram: &Graph,
geometry: &GraphGeometry,
routed: Option<&RoutedGraphGeometry>,
level: GeometryLevel,
path_simplification: PathSimplification,
engine_id: Option<&str>,
) -> Result<String, RenderError> {
let routed_owned = (routed.is_none() && matches!(level, GeometryLevel::Routed))
.then(|| route_graph_geometry(diagram, geometry, EdgeRouting::OrthogonalRoute));
let routed = routed.or(routed_owned.as_ref());
to_json_typed(
diagram_type,
diagram,
geometry,
routed,
level,
path_simplification,
engine_id,
)
}
fn render_output(
diagram_type: &str,
diagram: &Graph,
geometry: &GraphGeometry,
routed: Option<&RoutedGraphGeometry>,
path_simplification: PathSimplification,
engine_id: Option<&str>,
) -> String {
let output = build_output(
diagram_type,
diagram,
geometry,
routed,
path_simplification,
engine_id,
);
serialize_output(&output)
}
fn serialize_output(output: &Output) -> String {
serde_json::to_string_pretty(output).expect("MMDS serialization should not fail")
}
fn edge_port_to_mmds(port: &EdgePort) -> Port {
Port {
face: port.face.as_str().to_string(),
fraction: port.fraction,
position: Position {
x: port.position.x,
y: port.position.y,
},
group_size: port.group_size,
}
}
fn build_output(
diagram_type: &str,
diagram: &Graph,
geometry: &GraphGeometry,
routed: Option<&RoutedGraphGeometry>,
path_simplification: PathSimplification,
engine_id: Option<&str>,
) -> Output {
let level = if routed.is_some() { "routed" } else { "layout" };
let styled_nodes = collect_styled_nodes(diagram);
let effective_bounds = routed.map_or(geometry.bounds, |r| r.bounds);
let metadata = Metadata {
diagram_type: diagram_type.to_string(),
direction: direction_str(diagram.direction).to_string(),
bounds: Bounds {
width: effective_bounds.width,
height: effective_bounds.height,
},
engine: engine_id.map(|id| id.to_string()),
};
let mut nodes: Vec<Node> = geometry.nodes.values().map(node).collect();
nodes.sort_by(|a, b| a.id.cmp(&b.id));
let edges: Vec<Edge> = diagram
.edges
.iter()
.enumerate()
.map(|(i, edge)| {
let mut mmds_edge = Edge {
id: format!("e{i}"),
source: edge.from.clone(),
target: edge.to.clone(),
from_subgraph: edge.from_subgraph.clone(),
to_subgraph: edge.to_subgraph.clone(),
label: edge.label.clone(),
stroke: stroke_str(edge.stroke).to_string(),
arrow_start: arrow_str(edge.arrow_start).to_string(),
arrow_end: arrow_str(edge.arrow_end).to_string(),
minlen: edge.minlen,
path: None,
label_position: None,
is_backward: None,
source_port: None,
target_port: None,
};
if let Some(routed) = routed
&& let Some(re) = routed.edges.iter().find(|e| e.index == i)
{
let full_path: Vec<[f64; 2]> = re.path.iter().map(|p| [p.x, p.y]).collect();
mmds_edge.path = Some(
path_simplification
.simplify_with_coords(&full_path, |point| (point[0], point[1])),
);
mmds_edge.label_position = re.label_position.map(|p| Position { x: p.x, y: p.y });
mmds_edge.is_backward = Some(re.is_backward);
mmds_edge.source_port = re.source_port.as_ref().map(edge_port_to_mmds);
mmds_edge.target_port = re.target_port.as_ref().map(edge_port_to_mmds);
}
mmds_edge
})
.collect();
let mut subgraphs: Vec<Subgraph> = diagram
.subgraphs
.values()
.map(|sg| {
let direct_children: Vec<String> = sg
.nodes
.iter()
.filter(|node_id| {
diagram
.nodes
.get(*node_id)
.and_then(|n| n.parent.as_deref())
== Some(&sg.id)
})
.cloned()
.collect();
let bounds = routed.and_then(|r| {
r.subgraphs.get(&sg.id).map(|sg_geom| Bounds {
width: sg_geom.rect.width,
height: sg_geom.rect.height,
})
});
Subgraph {
id: sg.id.clone(),
title: sg.title.clone(),
children: direct_children,
parent: sg.parent.clone(),
direction: sg.dir.map(|d| direction_str(d).to_string()),
bounds,
invisible: sg.invisible,
}
})
.collect();
subgraphs.sort_by(|a, b| a.id.cmp(&b.id));
let mut profiles = Vec::new();
let mut extensions = BTreeMap::new();
if let Some(grid_projection) = &geometry.grid_projection {
push_profile(&mut profiles, CORE_PROFILE);
push_profile(&mut profiles, TEXT_PROFILE);
extensions.insert(
TEXT_EXTENSION_NAMESPACE.to_string(),
grid_projection_extension(grid_projection),
);
}
if !styled_nodes.is_empty() {
push_profile(&mut profiles, CORE_PROFILE);
push_profile(&mut profiles, NODE_STYLE_PROFILE);
extensions.insert(
NODE_STYLE_EXTENSION_NAMESPACE.to_string(),
node_style_extension(styled_nodes),
);
}
Output {
version: 1,
profiles,
extensions,
defaults: Defaults::default(),
geometry_level: level.to_string(),
metadata,
nodes,
edges,
subgraphs,
}
}
fn collect_styled_nodes(diagram: &Graph) -> BTreeMap<String, NodeStyle> {
diagram
.nodes
.iter()
.filter(|(_, node)| !node.style.is_empty())
.map(|(node_id, node)| (node_id.clone(), node.style.clone()))
.collect()
}
fn push_profile(profiles: &mut Vec<String>, profile: &str) {
if !profiles.iter().any(|existing| existing == profile) {
profiles.push(profile.to_string());
}
}
fn grid_projection_extension(grid_projection: &GridProjection) -> Map<String, Value> {
let mut extension = Map::new();
extension.insert(
"projection".to_string(),
Value::Object(serialize_grid_projection(grid_projection)),
);
extension
}
fn serialize_grid_projection(grid_projection: &GridProjection) -> Map<String, Value> {
let mut projection = Map::new();
projection.insert(
"node_ranks".to_string(),
Value::Object(
grid_projection
.node_ranks
.iter()
.map(|(node_id, rank)| (node_id.clone(), Value::Number(Number::from(*rank))))
.collect(),
),
);
projection.insert(
"edge_waypoints".to_string(),
Value::Object(
grid_projection
.edge_waypoints
.iter()
.map(|(edge_idx, waypoints)| {
(
edge_idx.to_string(),
Value::Array(
waypoints
.iter()
.map(|(point, rank)| ranked_point_value(*point, *rank))
.collect(),
),
)
})
.collect(),
),
);
projection.insert(
"label_positions".to_string(),
Value::Object(
grid_projection
.label_positions
.iter()
.map(|(edge_idx, (point, rank))| {
(edge_idx.to_string(), ranked_point_value(*point, *rank))
})
.collect(),
),
);
if !grid_projection.override_subgraphs.is_empty() {
projection.insert(
"override_subgraphs".to_string(),
Value::Object(
grid_projection
.override_subgraphs
.iter()
.map(|(subgraph_id, projection)| {
(
subgraph_id.clone(),
Value::Object(serialize_override_subgraph_projection(projection)),
)
})
.collect(),
),
);
}
projection
}
fn ranked_point_value(point: crate::graph::geometry::FPoint, rank: i32) -> Value {
let mut value = Map::new();
value.insert(
"x".to_string(),
Value::Number(Number::from_f64(point.x).expect("grid projection x should be finite")),
);
value.insert(
"y".to_string(),
Value::Number(Number::from_f64(point.y).expect("grid projection y should be finite")),
);
value.insert("rank".to_string(), Value::Number(Number::from(rank)));
Value::Object(value)
}
fn serialize_override_subgraph_projection(
projection: &OverrideSubgraphProjection,
) -> Map<String, Value> {
serialize_rect_map(&projection.nodes)
}
fn serialize_rect_map(
rects: &std::collections::HashMap<String, crate::graph::geometry::FRect>,
) -> Map<String, Value> {
rects
.iter()
.map(|(node_id, rect)| (node_id.clone(), rect_value(*rect)))
.collect()
}
fn rect_value(rect: crate::graph::geometry::FRect) -> Value {
let mut value = Map::new();
value.insert(
"x".to_string(),
Value::Number(Number::from_f64(rect.x).expect("subgraph projection x should be finite")),
);
value.insert(
"y".to_string(),
Value::Number(Number::from_f64(rect.y).expect("subgraph projection y should be finite")),
);
value.insert(
"width".to_string(),
Value::Number(
Number::from_f64(rect.width).expect("subgraph projection width should be finite"),
),
);
value.insert(
"height".to_string(),
Value::Number(
Number::from_f64(rect.height).expect("subgraph projection height should be finite"),
),
);
Value::Object(value)
}
fn node_style_extension(styled_nodes: BTreeMap<String, NodeStyle>) -> Map<String, Value> {
let nodes = styled_nodes
.iter()
.map(|(node_id, style)| {
(
node_id.clone(),
Value::Object(serialize_node_style_extension(style)),
)
})
.collect();
let mut extension = Map::new();
extension.insert("nodes".to_string(), Value::Object(nodes));
extension
}
fn serialize_node_style_extension(style: &NodeStyle) -> Map<String, Value> {
let mut payload = Map::new();
if let Some(fill) = &style.fill {
payload.insert("fill".to_string(), Value::String(fill.raw().to_string()));
}
if let Some(stroke) = &style.stroke {
payload.insert(
"stroke".to_string(),
Value::String(stroke.raw().to_string()),
);
}
if let Some(color) = &style.color {
payload.insert("color".to_string(), Value::String(color.raw().to_string()));
}
payload
}
fn node(pn: &PositionedNode) -> Node {
Node {
id: pn.id.clone(),
label: pn.label.clone(),
shape: shape_str(pn.shape).to_string(),
parent: pn.parent.clone(),
position: Position {
x: pn.rect.x + pn.rect.width / 2.0,
y: pn.rect.y + pn.rect.height / 2.0,
},
size: Size {
width: pn.rect.width,
height: pn.rect.height,
},
}
}
fn direction_str(dir: Direction) -> &'static str {
match dir {
Direction::TopDown => "TD",
Direction::BottomTop => "BT",
Direction::LeftRight => "LR",
Direction::RightLeft => "RL",
}
}
fn shape_str(shape: Shape) -> &'static str {
match shape {
Shape::Rectangle => "rectangle",
Shape::Round => "round",
Shape::Stadium => "stadium",
Shape::Subroutine => "subroutine",
Shape::Cylinder => "cylinder",
Shape::Document => "document",
Shape::Documents => "documents",
Shape::TaggedDocument => "tagged_document",
Shape::Card => "card",
Shape::TaggedRect => "tagged_rect",
Shape::Diamond => "diamond",
Shape::Hexagon => "hexagon",
Shape::Trapezoid => "trapezoid",
Shape::InvTrapezoid => "inv_trapezoid",
Shape::Parallelogram => "parallelogram",
Shape::InvParallelogram => "inv_parallelogram",
Shape::ManualInput => "manual_input",
Shape::Asymmetric => "asymmetric",
Shape::Circle => "circle",
Shape::DoubleCircle => "double_circle",
Shape::SmallCircle => "small_circle",
Shape::FramedCircle => "framed_circle",
Shape::CrossedCircle => "crossed_circle",
Shape::TextBlock => "text_block",
Shape::ForkJoin => "fork_join",
Shape::NoteRect => "note_rect",
}
}
fn stroke_str(stroke: Stroke) -> &'static str {
match stroke {
Stroke::Solid => "solid",
Stroke::Dotted => "dotted",
Stroke::Dashed => "dashed",
Stroke::Thick => "thick",
Stroke::Invisible => "invisible",
}
}
fn arrow_str(arrow: Arrow) -> &'static str {
match arrow {
Arrow::Normal => "normal",
Arrow::None => "none",
Arrow::Cross => "cross",
Arrow::Circle => "circle",
Arrow::OpenTriangle => "open_triangle",
Arrow::Diamond => "diamond",
Arrow::OpenDiamond => "open_diamond",
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Output {
pub version: u32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub profiles: Vec<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub extensions: BTreeMap<String, Map<String, Value>>,
pub defaults: Defaults,
pub geometry_level: String,
pub metadata: Metadata,
pub nodes: Vec<Node>,
pub edges: Vec<Edge>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub subgraphs: Vec<Subgraph>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Defaults {
pub node: NodeDefaults,
pub edge: EdgeDefaults,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeDefaults {
#[serde(default = "default_node_shape")]
pub shape: String,
}
impl Default for NodeDefaults {
fn default() -> Self {
Self {
shape: default_node_shape(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EdgeDefaults {
#[serde(default = "default_stroke")]
pub stroke: String,
#[serde(default = "default_arrow_start")]
pub arrow_start: String,
#[serde(default = "default_arrow_end")]
pub arrow_end: String,
#[serde(default = "default_minlen")]
pub minlen: i32,
}
impl Default for EdgeDefaults {
fn default() -> Self {
Self {
stroke: default_stroke(),
arrow_start: default_arrow_start(),
arrow_end: default_arrow_end(),
minlen: default_minlen(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Metadata {
pub diagram_type: String,
pub direction: String,
pub bounds: Bounds,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub engine: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Bounds {
pub width: f64,
pub height: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Node {
pub id: String,
pub label: String,
#[serde(
default = "default_node_shape",
skip_serializing_if = "is_default_node_shape"
)]
pub shape: String,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub parent: Option<String>,
pub position: Position,
pub size: Size,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Position {
pub x: f64,
pub y: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Size {
pub width: f64,
pub height: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Port {
pub face: String,
pub fraction: f64,
pub position: Position,
pub group_size: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Edge {
pub id: String,
pub source: String,
pub target: String,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub from_subgraph: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub to_subgraph: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub label: Option<String>,
#[serde(default = "default_stroke", skip_serializing_if = "is_default_stroke")]
pub stroke: String,
#[serde(
default = "default_arrow_start",
skip_serializing_if = "is_default_arrow_start"
)]
pub arrow_start: String,
#[serde(
default = "default_arrow_end",
skip_serializing_if = "is_default_arrow_end"
)]
pub arrow_end: String,
#[serde(default = "default_minlen", skip_serializing_if = "is_default_minlen")]
pub minlen: i32,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub path: Option<Vec<[f64; 2]>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub label_position: Option<Position>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub is_backward: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub source_port: Option<Port>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub target_port: Option<Port>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Subgraph {
pub id: String,
pub title: String,
pub children: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub parent: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub direction: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub bounds: Option<Bounds>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub invisible: bool,
}
fn default_node_shape() -> String {
"rectangle".to_string()
}
fn default_stroke() -> String {
"solid".to_string()
}
fn default_arrow_start() -> String {
"none".to_string()
}
fn default_arrow_end() -> String {
"normal".to_string()
}
fn default_minlen() -> i32 {
1
}
fn is_default_node_shape(value: &String) -> bool {
value == "rectangle"
}
fn is_default_stroke(value: &String) -> bool {
value == "solid"
}
fn is_default_arrow_start(value: &String) -> bool {
value == "none"
}
fn is_default_arrow_end(value: &String) -> bool {
value == "normal"
}
fn is_default_minlen(value: &i32) -> bool {
*value == 1
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mmds_port_serializes_correctly() {
let port = Port {
face: "bottom".to_string(),
fraction: 0.5,
position: Position { x: 50.0, y: 35.0 },
group_size: 1,
};
let json = serde_json::to_string(&port).unwrap();
assert!(json.contains("\"face\":\"bottom\""));
assert!(json.contains("\"fraction\":0.5"));
assert!(json.contains("\"group_size\":1"));
}
#[test]
fn mmds_edge_source_port_none_omitted_from_json() {
let edge = Edge {
id: "e0".into(),
source: "A".into(),
target: "B".into(),
from_subgraph: None,
to_subgraph: None,
label: None,
stroke: "solid".into(),
arrow_start: "none".into(),
arrow_end: "normal".into(),
minlen: 1,
path: None,
label_position: None,
is_backward: None,
source_port: None,
target_port: None,
};
let json = serde_json::to_string(&edge).unwrap();
assert!(!json.contains("source_port"));
assert!(!json.contains("target_port"));
}
#[test]
fn mmds_edge_source_port_round_trips() {
let port = Port {
face: "right".to_string(),
fraction: 0.3,
position: Position { x: 100.0, y: 30.0 },
group_size: 2,
};
let edge = Edge {
id: "e0".into(),
source: "A".into(),
target: "B".into(),
from_subgraph: None,
to_subgraph: None,
label: None,
stroke: "solid".into(),
arrow_start: "none".into(),
arrow_end: "normal".into(),
minlen: 1,
path: None,
label_position: None,
is_backward: None,
source_port: Some(port),
target_port: None,
};
let json = serde_json::to_string(&edge).unwrap();
let deserialized: Edge = serde_json::from_str(&json).unwrap();
let sp = deserialized.source_port.unwrap();
assert_eq!(sp.face, "right");
assert!((sp.fraction - 0.3).abs() < 1e-9);
assert!((sp.position.x - 100.0).abs() < 1e-9);
assert_eq!(sp.group_size, 2);
assert!(deserialized.target_port.is_none());
}
#[test]
fn mmds_edge_deserializes_without_ports() {
let json = r#"{
"id": "e0",
"source": "A",
"target": "B"
}"#;
let edge: Edge = serde_json::from_str(json).unwrap();
assert!(edge.source_port.is_none());
assert!(edge.target_port.is_none());
}
}