Skip to main content

xpict_core/
elk.rs

1//! Multi-molecule diagram placement via **elkrs** (native ELK).
2//!
3//! Used by [`crate::doc::compose_scheme`] for reaction_scheme documents.
4
5#![cfg(feature = "elk")]
6
7use std::collections::BTreeMap;
8
9use crate::doc::{EdgeRouting, LayoutAlgorithm, LayoutDirection, LayoutOpts};
10
11/// Whether the diagram uses reaction packing defaults (vs generic network).
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum DiagramKind {
14    Network,
15    Reaction,
16}
17
18fn direction_elk(d: LayoutDirection) -> &'static str {
19    match d {
20        LayoutDirection::Right => "RIGHT",
21        LayoutDirection::Left => "LEFT",
22        LayoutDirection::Up => "UP",
23        LayoutDirection::Down => "DOWN",
24    }
25}
26
27fn routing_elk(r: EdgeRouting) -> &'static str {
28    match r {
29        EdgeRouting::Orthogonal => "ORTHOGONAL",
30        EdgeRouting::Polyline => "POLYLINE",
31        EdgeRouting::Splines => "SPLINES",
32    }
33}
34
35fn algorithm_elk(a: LayoutAlgorithm) -> &'static str {
36    match a {
37        LayoutAlgorithm::Layered => "layered",
38        LayoutAlgorithm::Radial => "radial",
39        LayoutAlgorithm::Force => "force",
40        LayoutAlgorithm::Stress => "stress",
41    }
42}
43
44/// Backend-agnostic [`LayoutOpts`] → ELK `layoutOptions` map.
45///
46/// Reaction diagrams pack tightly (node spacing ~20, layer spacing ~0) and pin
47/// `unnecessaryBendpoints=false` so shafts only bend where they turn.
48pub fn scheme_layout_options(
49    kind: DiagramKind,
50    opts: &LayoutOpts,
51) -> BTreeMap<String, String> {
52    let mut base: BTreeMap<String, String> = BTreeMap::from([
53        ("elk.algorithm".into(), "layered".into()),
54        ("elk.direction".into(), "RIGHT".into()),
55        ("elk.edgeRouting".into(), "POLYLINE".into()),
56        // Keep shafts simple: only bend where the edge actually turns.
57        ("elk.layered.unnecessaryBendpoints".into(), "false".into()),
58        ("elk.spacing.nodeNode".into(), "40".into()),
59        ("elk.spacing.edgeEdge".into(), "16".into()),
60        ("elk.spacing.edgeNode".into(), "20".into()),
61        ("elk.layered.spacing.nodeNodeBetweenLayers".into(), "48".into()),
62        ("elk.layered.spacing.edgeNodeBetweenLayers".into(), "24".into()),
63        (
64            "elk.layered.crossingMinimization.strategy".into(),
65            "LAYER_SWEEP".into(),
66        ),
67        (
68            "elk.layered.nodePlacement.strategy".into(),
69            "NETWORK_SIMPLEX".into(),
70        ),
71    ]);
72
73    if kind == DiagramKind::Reaction {
74        base.insert(
75            "elk.spacing.nodeNode".into(),
76            LayoutOpts::DEFAULT_NODE_SPACING.to_string(),
77        );
78        base.insert(
79            "elk.layered.spacing.nodeNodeBetweenLayers".into(),
80            LayoutOpts::DEFAULT_LAYER_SPACING.to_string(),
81        );
82        base.insert(
83            "elk.layered.spacing.edgeNodeBetweenLayers".into(),
84            LayoutOpts::DEFAULT_LAYER_SPACING.to_string(),
85        );
86        base.insert("elk.spacing.edgeEdge".into(), "16".into());
87        base.insert(
88            "elk.layered.crossingMinimization.forceNodeModelOrder".into(),
89            "false".into(),
90        );
91    }
92
93    if opts.algorithm.is_some() {
94        base.insert(
95            "elk.algorithm".into(),
96            algorithm_elk(opts.algorithm_or_default()).into(),
97        );
98    }
99    if opts.direction.is_some() {
100        base.insert(
101            "elk.direction".into(),
102            direction_elk(opts.direction_or_default()).into(),
103        );
104    }
105    if opts.edge_routing.is_some() {
106        base.insert(
107            "elk.edgeRouting".into(),
108            routing_elk(opts.edge_routing_or_default()).into(),
109        );
110    }
111    if let Some(ns) = opts.node_spacing {
112        base.insert("elk.spacing.nodeNode".into(), ns.to_string());
113    }
114    if let Some(ls) = opts.layer_spacing {
115        base.insert(
116            "elk.layered.spacing.nodeNodeBetweenLayers".into(),
117            ls.to_string(),
118        );
119        base.insert(
120            "elk.layered.spacing.edgeNodeBetweenLayers".into(),
121            (ls * 0.5).max(0.0).to_string(),
122        );
123    }
124    base
125}
126
127/// Lay out an ELK JSON graph string; returns laid-out JSON.
128///
129/// Errors are returned as strings (invalid JSON, unknown algorithm, …).
130pub fn layout_json(input: &str) -> Result<String, String> {
131    let value = elkrs::create_elk().layout_json(input)?;
132    Ok(value.to_string())
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn reaction_defaults_tight_pack_no_extra_bends() {
141        let opts = LayoutOpts::default();
142        let m = scheme_layout_options(DiagramKind::Reaction, &opts);
143        assert_eq!(m.get("elk.algorithm").unwrap(), "layered");
144        assert_eq!(m.get("elk.layered.unnecessaryBendpoints").unwrap(), "false");
145        assert_eq!(
146            m.get("elk.spacing.nodeNode").unwrap(),
147            &LayoutOpts::DEFAULT_NODE_SPACING.to_string()
148        );
149        assert_eq!(
150            m.get("elk.layered.spacing.nodeNodeBetweenLayers").unwrap(),
151            &LayoutOpts::DEFAULT_LAYER_SPACING.to_string()
152        );
153    }
154
155    #[test]
156    fn algorithm_override_selects_force() {
157        let opts = LayoutOpts {
158            algorithm: Some(LayoutAlgorithm::Force),
159            ..Default::default()
160        };
161        let m = scheme_layout_options(DiagramKind::Network, &opts);
162        assert_eq!(m.get("elk.algorithm").unwrap(), "force");
163    }
164
165    #[test]
166    fn layout_opts_map_direction_routing_spacing() {
167        for (dir, want) in [
168            (LayoutDirection::Right, "RIGHT"),
169            (LayoutDirection::Left, "LEFT"),
170            (LayoutDirection::Up, "UP"),
171            (LayoutDirection::Down, "DOWN"),
172        ] {
173            let opts = LayoutOpts {
174                direction: Some(dir),
175                ..Default::default()
176            };
177            let m = scheme_layout_options(DiagramKind::Network, &opts);
178            assert_eq!(m.get("elk.direction").unwrap(), want);
179        }
180        for (r, want) in [
181            (EdgeRouting::Orthogonal, "ORTHOGONAL"),
182            (EdgeRouting::Polyline, "POLYLINE"),
183            (EdgeRouting::Splines, "SPLINES"),
184        ] {
185            let opts = LayoutOpts {
186                edge_routing: Some(r),
187                ..Default::default()
188            };
189            let m = scheme_layout_options(DiagramKind::Network, &opts);
190            assert_eq!(m.get("elk.edgeRouting").unwrap(), want);
191        }
192        for (a, want) in [
193            (LayoutAlgorithm::Layered, "layered"),
194            (LayoutAlgorithm::Radial, "radial"),
195            (LayoutAlgorithm::Force, "force"),
196            (LayoutAlgorithm::Stress, "stress"),
197        ] {
198            let opts = LayoutOpts {
199                algorithm: Some(a),
200                ..Default::default()
201            };
202            assert_eq!(
203                scheme_layout_options(DiagramKind::Reaction, &opts)
204                    .get("elk.algorithm")
205                    .unwrap(),
206                want
207            );
208        }
209        let opts = LayoutOpts {
210            node_spacing: Some(24.0),
211            layer_spacing: Some(40.0),
212            ..Default::default()
213        };
214        let m = scheme_layout_options(DiagramKind::Reaction, &opts);
215        assert_eq!(m.get("elk.spacing.nodeNode").unwrap(), "24");
216        assert_eq!(
217            m.get("elk.layered.spacing.nodeNodeBetweenLayers").unwrap(),
218            "40"
219        );
220        assert_eq!(
221            m.get("elk.layered.spacing.edgeNodeBetweenLayers").unwrap(),
222            "20"
223        );
224    }
225
226    #[test]
227    fn layered_orthogonal_emits_edge_sections() {
228        let input = r#"{
229          "id": "root",
230          "layoutOptions": {
231            "elk.algorithm": "layered",
232            "elk.direction": "RIGHT",
233            "elk.edgeRouting": "ORTHOGONAL"
234          },
235          "children": [
236            {"id": "a", "width": 40.0, "height": 30.0},
237            {"id": "extra", "width": 10.0, "height": 10.0},
238            {"id": "b", "width": 40.0, "height": 30.0}
239          ],
240          "edges": [{"id": "e0", "sources": ["a"], "targets": ["b"]}]
241        }"#;
242        let out = layout_json(input).expect("layout");
243        assert!(out.contains("\"x\""), "{out}");
244        assert!(
245            out.contains("sections") && out.contains("startPoint"),
246            "{out}"
247        );
248        // Parity with Python ``test_elk_layout_json_layered``: b is right of a.
249        let parsed: serde_json::Value = serde_json::from_str(&out).expect("json");
250        let children = parsed["children"].as_array().expect("children");
251        let mut ax = None;
252        let mut bx = None;
253        for c in children {
254            match c["id"].as_str() {
255                Some("a") => ax = c["x"].as_f64(),
256                Some("b") => bx = c["x"].as_f64(),
257                _ => {}
258            }
259        }
260        assert!(bx.unwrap() > ax.unwrap(), "b.x should be > a.x: {out}");
261        let edge = &parsed["edges"][0];
262        assert!(edge["sections"].as_array().map(|s| !s.is_empty()).unwrap_or(false));
263        assert!(edge["sections"][0].get("startPoint").is_some());
264    }
265
266    #[test]
267    fn layered_ignores_extra_children_in_assert_loop() {
268        // Extra node exercises the `_` arm when scanning children by id.
269        let input = r#"{
270          "id": "root",
271          "layoutOptions": {
272            "elk.algorithm": "layered",
273            "elk.direction": "RIGHT",
274            "elk.edgeRouting": "ORTHOGONAL"
275          },
276          "children": [
277            {"id": "a", "width": 40.0, "height": 30.0},
278            {"id": "extra", "width": 20.0, "height": 20.0},
279            {"id": "b", "width": 40.0, "height": 30.0}
280          ],
281          "edges": [{"id": "e0", "sources": ["a"], "targets": ["b"]}]
282        }"#;
283        let out = layout_json(input).expect("layout");
284        let parsed: serde_json::Value = serde_json::from_str(&out).expect("json");
285        assert!(parsed["children"].as_array().unwrap().len() >= 3);
286    }
287}