use std::collections::BTreeMap;
use crate::model::flow_ast::{FlowEdge, FlowNode, FlowchartAst};
use crate::model::gantt_ast::GanttAst;
use crate::model::ids::ObjectId;
use crate::model::seq_ast::SequenceAst;
use crate::model::symbol::SymbolAnchor;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
pub enum CapKind {
#[default]
None,
Arrow,
Circle,
Cross,
DiamondFilled,
DiamondHollow,
TriangleHollow,
ExactlyOne,
CrowFoot,
ZeroOrMore,
ZeroOrOne,
}
impl CapKind {
pub fn from_flow_connector(connector: Option<&str>) -> (Self, Self) {
let op = connector.unwrap_or("-->").trim();
if op.is_empty() {
return (Self::None, Self::None);
}
let start = match op.chars().next() {
Some('<') => Self::Arrow,
Some('o') => Self::Circle,
Some('x') => Self::Cross,
_ => Self::None,
};
let end = if op.ends_with('o') {
Self::Circle
} else if op.ends_with('x') {
Self::Cross
} else if op.ends_with('>') {
Self::Arrow
} else {
Self::None
};
(start, end)
}
pub fn glyph(self, outward_dx: i32, outward_dy: i32) -> Option<char> {
let toward_dx = -outward_dx;
let toward_dy = -outward_dy;
match self {
Self::None => None,
Self::Arrow => Some(arrow_glyph(toward_dx, toward_dy)),
Self::Circle | Self::ZeroOrOne => Some('○'),
Self::Cross => Some('✕'),
Self::DiamondFilled => Some('◆'),
Self::DiamondHollow => Some('◇'),
Self::TriangleHollow => Some(triangle_glyph(toward_dx, toward_dy)),
Self::ExactlyOne => Some('‖'),
Self::CrowFoot => Some(crow_glyph(toward_dx, toward_dy)),
Self::ZeroOrMore => Some(zero_or_more_glyph(toward_dx, toward_dy)),
}
}
}
fn arrow_glyph(toward_dx: i32, toward_dy: i32) -> char {
if toward_dx.abs() >= toward_dy.abs() {
if toward_dx < 0 {
'◀'
} else if toward_dx > 0 {
'▶'
} else if toward_dy < 0 {
'▲'
} else {
'▼'
}
} else if toward_dy < 0 {
'▲'
} else {
'▼'
}
}
fn triangle_glyph(toward_dx: i32, toward_dy: i32) -> char {
if toward_dx.abs() >= toward_dy.abs() {
if toward_dx < 0 {
'◁'
} else if toward_dx > 0 {
'▷'
} else if toward_dy < 0 {
'△'
} else {
'▽'
}
} else if toward_dy < 0 {
'△'
} else {
'▽'
}
}
fn crow_glyph(toward_dx: i32, toward_dy: i32) -> char {
if toward_dx.abs() >= toward_dy.abs() {
if toward_dx < 0 {
'⊂'
} else if toward_dx > 0 {
'⊃'
} else if toward_dy < 0 {
'∪'
} else {
'∩'
}
} else if toward_dy < 0 {
'∪'
} else {
'∩'
}
}
fn zero_or_more_glyph(toward_dx: i32, toward_dy: i32) -> char {
if toward_dx.abs() >= toward_dy.abs() {
if toward_dx < 0 {
'⋉'
} else if toward_dx > 0 {
'⋊'
} else if toward_dy < 0 {
'⋏'
} else {
'⋎'
}
} else if toward_dy < 0 {
'⋏'
} else {
'⋎'
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum EdgeStroke {
#[default]
Solid,
Dashed,
}
impl EdgeStroke {
pub fn from_flow_connector_and_style(connector: Option<&str>, style: Option<&str>) -> Self {
if style.is_some_and(|s| s.to_ascii_lowercase().contains("dashed")) {
return Self::Dashed;
}
let op = connector.unwrap_or("");
if op.contains('.') {
Self::Dashed
} else {
Self::Solid
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct GraphCompartment {
lines: Vec<String>,
}
impl GraphCompartment {
pub fn new(lines: impl IntoIterator<Item = impl Into<String>>) -> Self {
Self { lines: lines.into_iter().map(Into::into).collect() }
}
pub fn lines(&self) -> &[String] {
&self.lines
}
pub fn is_empty(&self) -> bool {
self.lines.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GraphNode {
mermaid_id: Option<String>,
label: String,
shape: String,
note: Option<String>,
symbol: Option<SymbolAnchor>,
compartments: Vec<GraphCompartment>,
}
impl GraphNode {
pub fn new(label: impl Into<String>) -> Self {
Self {
mermaid_id: None,
label: label.into(),
shape: "rect".to_owned(),
note: None,
symbol: None,
compartments: Vec::new(),
}
}
pub fn with_mermaid_id(mut self, mermaid_id: Option<impl Into<String>>) -> Self {
self.mermaid_id = mermaid_id.map(Into::into);
self
}
pub fn with_shape(mut self, shape: impl Into<String>) -> Self {
self.shape = shape.into();
self
}
pub fn with_note(mut self, note: Option<impl Into<String>>) -> Self {
self.note = note.map(Into::into);
self
}
pub fn with_symbol(mut self, symbol: Option<SymbolAnchor>) -> Self {
self.symbol = symbol;
self
}
pub fn with_compartments(mut self, compartments: Vec<GraphCompartment>) -> Self {
self.compartments = compartments;
self
}
pub fn mermaid_id(&self) -> Option<&str> {
self.mermaid_id.as_deref()
}
pub fn label(&self) -> &str {
&self.label
}
pub fn shape(&self) -> &str {
&self.shape
}
pub fn note(&self) -> Option<&str> {
self.note.as_deref()
}
pub fn symbol(&self) -> Option<&SymbolAnchor> {
self.symbol.as_ref()
}
pub fn compartments(&self) -> &[GraphCompartment] {
&self.compartments
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GraphEdge {
from_node_id: ObjectId,
to_node_id: ObjectId,
label: Option<String>,
connector: Option<String>,
style: Option<String>,
start_cap: CapKind,
end_cap: CapKind,
stroke: EdgeStroke,
}
impl GraphEdge {
pub fn new(from_node_id: ObjectId, to_node_id: ObjectId) -> Self {
Self {
from_node_id,
to_node_id,
label: None,
connector: None,
style: None,
start_cap: CapKind::None,
end_cap: CapKind::Arrow,
stroke: EdgeStroke::Solid,
}
}
pub fn with_label(mut self, label: Option<impl Into<String>>) -> Self {
self.label = label.map(Into::into);
self
}
pub fn with_connector(mut self, connector: Option<impl Into<String>>) -> Self {
self.connector = connector.map(Into::into);
self
}
pub fn with_style(mut self, style: Option<impl Into<String>>) -> Self {
self.style = style.map(Into::into);
self
}
pub fn with_caps(mut self, start_cap: CapKind, end_cap: CapKind) -> Self {
self.start_cap = start_cap;
self.end_cap = end_cap;
self
}
pub fn with_stroke(mut self, stroke: EdgeStroke) -> Self {
self.stroke = stroke;
self
}
pub fn from_node_id(&self) -> &ObjectId {
&self.from_node_id
}
pub fn to_node_id(&self) -> &ObjectId {
&self.to_node_id
}
pub fn label(&self) -> Option<&str> {
self.label.as_deref()
}
pub fn connector(&self) -> Option<&str> {
self.connector.as_deref()
}
pub fn style(&self) -> Option<&str> {
self.style.as_deref()
}
pub fn start_cap(&self) -> CapKind {
self.start_cap
}
pub fn end_cap(&self) -> CapKind {
self.end_cap
}
pub fn stroke(&self) -> EdgeStroke {
self.stroke
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct GraphModel {
nodes: BTreeMap<ObjectId, GraphNode>,
edges: BTreeMap<ObjectId, GraphEdge>,
}
impl GraphModel {
pub fn nodes(&self) -> &BTreeMap<ObjectId, GraphNode> {
&self.nodes
}
pub fn nodes_mut(&mut self) -> &mut BTreeMap<ObjectId, GraphNode> {
&mut self.nodes
}
pub fn edges(&self) -> &BTreeMap<ObjectId, GraphEdge> {
&self.edges
}
pub fn edges_mut(&mut self) -> &mut BTreeMap<ObjectId, GraphEdge> {
&mut self.edges
}
pub fn to_flowchart_ast(&self) -> FlowchartAst {
let mut ast = FlowchartAst::default();
for (id, node) in &self.nodes {
let mut flow_node =
FlowNode::new_with(node.label.clone(), node.shape.clone(), node.mermaid_id.clone());
flow_node.set_note(node.note.clone());
flow_node.set_symbol(node.symbol.clone());
ast.nodes_mut().insert(id.clone(), flow_node);
}
for (id, edge) in &self.edges {
let mut flow_edge = FlowEdge::new(edge.from_node_id.clone(), edge.to_node_id.clone());
flow_edge.set_label(edge.label.clone());
flow_edge.set_connector(edge.connector.clone());
flow_edge.set_style(edge.style.clone());
ast.edges_mut().insert(id.clone(), flow_edge);
}
ast
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum TrackSpanStyle {
#[default]
Arrow,
Bar,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TrackModel {
sequence: SequenceAst,
gantt: Option<GanttAst>,
default_span_style: TrackSpanStyle,
}
impl TrackModel {
pub fn from_sequence(sequence: SequenceAst) -> Self {
Self { sequence, gantt: None, default_span_style: TrackSpanStyle::Arrow }
}
pub fn from_gantt(gantt: GanttAst) -> Self {
Self {
sequence: SequenceAst::default(),
gantt: Some(gantt),
default_span_style: TrackSpanStyle::Bar,
}
}
pub fn as_sequence_ast(&self) -> &SequenceAst {
&self.sequence
}
pub fn into_sequence_ast(self) -> SequenceAst {
self.sequence
}
pub fn as_gantt_ast(&self) -> Option<&GanttAst> {
self.gantt.as_ref()
}
pub fn default_span_style(&self) -> TrackSpanStyle {
self.default_span_style
}
pub fn with_default_span_style(mut self, style: TrackSpanStyle) -> Self {
self.default_span_style = style;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RenderScene {
Graph(GraphModel),
Track(TrackModel),
}
impl RenderScene {
pub fn family_name(&self) -> &'static str {
match self {
Self::Graph(_) => "graph",
Self::Track(_) => "track",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::flow_ast::{FlowEdge, FlowNode};
use crate::model::ids::ObjectId;
fn oid(s: &str) -> ObjectId {
ObjectId::new(s).expect("object id")
}
#[test]
fn flow_connector_caps_match_legacy_rules() {
assert_eq!(CapKind::from_flow_connector(Some("-->")), (CapKind::None, CapKind::Arrow));
assert_eq!(CapKind::from_flow_connector(Some("<-->")), (CapKind::Arrow, CapKind::Arrow));
assert_eq!(CapKind::from_flow_connector(Some("---o")), (CapKind::None, CapKind::Circle));
assert_eq!(CapKind::from_flow_connector(Some("o--x")), (CapKind::Circle, CapKind::Cross));
assert_eq!(CapKind::from_flow_connector(None), (CapKind::None, CapKind::Arrow));
}
#[test]
fn every_cap_kind_emits_exactly_one_unicode_scalar_when_present() {
for kind in [
CapKind::None,
CapKind::Arrow,
CapKind::Circle,
CapKind::Cross,
CapKind::DiamondFilled,
CapKind::DiamondHollow,
CapKind::TriangleHollow,
CapKind::ExactlyOne,
CapKind::CrowFoot,
CapKind::ZeroOrOne,
CapKind::ZeroOrMore,
] {
for (dx, dy) in [(1, 0), (-1, 0), (0, 1), (0, -1)] {
let g = kind.glyph(dx, dy);
if kind == CapKind::None {
assert!(g.is_none());
} else {
let ch = g.expect("cap paints one cell");
assert!(ch.len_utf8() > 0);
}
}
}
}
#[test]
fn arrow_circle_cross_glyphs_match_legacy_endpoint_orientations() {
assert_eq!(CapKind::Arrow.glyph(1, 0), Some('◀'));
assert_eq!(CapKind::Arrow.glyph(-1, 0), Some('▶'));
assert_eq!(CapKind::Arrow.glyph(0, 1), Some('▲'));
assert_eq!(CapKind::Arrow.glyph(0, -1), Some('▼'));
assert_eq!(CapKind::Circle.glyph(1, 0), Some('○'));
assert_eq!(CapKind::Cross.glyph(1, 0), Some('✕'));
}
#[test]
fn edge_stroke_detects_dotted_connectors_and_dashed_style() {
assert_eq!(EdgeStroke::from_flow_connector_and_style(Some("-->"), None), EdgeStroke::Solid);
assert_eq!(
EdgeStroke::from_flow_connector_and_style(Some("-.->"), None),
EdgeStroke::Dashed
);
assert_eq!(
EdgeStroke::from_flow_connector_and_style(Some("-->"), Some("stroke-dasharray: 5")),
EdgeStroke::Solid
);
assert_eq!(
EdgeStroke::from_flow_connector_and_style(Some("-->"), Some("dashed")),
EdgeStroke::Dashed
);
}
#[test]
fn graph_model_bridge_roundtrip_preserves_flow_fields() {
let mut ast = FlowchartAst::default();
let a = oid("n:a");
let b = oid("n:b");
let mut na = FlowNode::new_with("A", "circle", Some("A".to_owned()));
na.set_note(Some("note-a".to_owned()));
ast.nodes_mut().insert(a.clone(), na);
ast.nodes_mut().insert(b.clone(), FlowNode::new("B"));
let mut edge = FlowEdge::new(a.clone(), b.clone());
edge.set_label(Some("go".to_owned()));
edge.set_connector(Some("<-->".to_owned()));
edge.set_style(Some("dashed".to_owned()));
ast.edges_mut().insert(oid("e:1"), edge);
let model = crate::render::lower::lower_flowchart(&ast);
assert_eq!(model.nodes().get(&a).unwrap().shape(), "circle");
assert_eq!(model.edges().get(&oid("e:1")).unwrap().stroke(), EdgeStroke::Dashed);
assert_eq!(model.edges().get(&oid("e:1")).unwrap().start_cap(), CapKind::Arrow);
assert_eq!(model.edges().get(&oid("e:1")).unwrap().end_cap(), CapKind::Arrow);
let back = model.to_flowchart_ast();
assert_eq!(back.nodes().len(), 2);
assert_eq!(back.nodes().get(&a).unwrap().label(), "A");
assert_eq!(back.nodes().get(&a).unwrap().shape(), "circle");
assert_eq!(back.nodes().get(&a).unwrap().mermaid_id(), Some("A"));
assert_eq!(back.nodes().get(&a).unwrap().note(), Some("note-a"));
assert_eq!(back.edges().get(&oid("e:1")).unwrap().connector(), Some("<-->"));
assert_eq!(back.edges().get(&oid("e:1")).unwrap().label(), Some("go"));
assert_eq!(back.edges().get(&oid("e:1")).unwrap().style(), Some("dashed"));
}
}