Skip to content

Commit 4ec48e3

Browse files
committed
refactor: add flowchart layout plan scaffold
1 parent 8c6f1fb commit 4ec48e3

4 files changed

Lines changed: 348 additions & 0 deletions

File tree

src/layout/flowchart/edge_pipeline.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ use super::super::{
1313
MIN_NODE_SPACING_FLOOR, MULTI_EDGE_OFFSET_RATIO, NodeLayout, SubgraphLayout, TextBlock,
1414
anchor_layout_for_edge,
1515
};
16+
use super::plan;
1617
use super::post_route;
1718
use super::roles;
1819
use super::route_labels;
@@ -845,6 +846,24 @@ pub(in crate::layout) fn build_routed_edges(ctx: RoutedEdgeBuildContext<'_>) ->
845846
graph.direction,
846847
graph.kind,
847848
);
849+
if graph.kind == DiagramKind::Flowchart {
850+
let route_label_centers = route_labels::route_label_centers(&route_label_plans);
851+
let plan_snapshot = plan::FlowchartLayoutPlan::from_current_pipeline(
852+
graph,
853+
nodes,
854+
subgraphs,
855+
&edge_ports,
856+
&pair_counts,
857+
&pair_index,
858+
&cross_edge_offsets,
859+
&routed_points,
860+
&label_anchors,
861+
&route_label_centers,
862+
edge_route_labels,
863+
config,
864+
);
865+
debug_assert!(plan_snapshot.is_consistent());
866+
}
848867
if let Some(metrics) = stage_metrics {
849868
metrics.edge_routing_us = metrics
850869
.edge_routing_us

src/layout/flowchart/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ pub(super) mod finalize;
33
pub(super) mod manual_layout;
44
pub(super) mod objectives;
55
pub(super) mod path_cleanup;
6+
pub(super) mod plan;
67
pub(super) mod policy;
78
pub(super) mod post_route;
89
pub(super) mod roles;

src/layout/flowchart/plan.rs

Lines changed: 321 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,321 @@
1+
#![allow(dead_code)]
2+
3+
//! Internal flowchart layout plan model.
4+
//!
5+
//! This module is the migration seam for the flowchart layout redesign. The
6+
//! first version mirrors facts already produced by the existing pipeline without
7+
//! changing output. Later phases can move bundle, route, and label decisions
8+
//! into this model one stage at a time.
9+
10+
use std::collections::{BTreeMap, HashMap};
11+
12+
use crate::config::LayoutConfig;
13+
use crate::ir::Graph;
14+
15+
use super::super::routing::{
16+
EdgePortInfo, EdgeSide, anchor_point_for_node, edge_pair_key, port_stub_length,
17+
};
18+
use super::super::{
19+
FLOWCHART_PORT_ROUTE_BIAS_MAX_RATIO, FLOWCHART_PORT_ROUTE_BIAS_RATIO, MULTI_EDGE_OFFSET_RATIO,
20+
NodeLayout, SubgraphLayout, TextBlock, anchor_layout_for_edge,
21+
};
22+
23+
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
24+
pub(super) struct BundleKey {
25+
pub(super) a: String,
26+
pub(super) b: String,
27+
}
28+
29+
#[derive(Clone, Debug)]
30+
pub(super) struct FlowchartLayoutPlan {
31+
pub(super) edge_count: usize,
32+
pub(super) ports: Vec<EdgePortPlan>,
33+
pub(super) bundles: Vec<EdgeBundlePlan>,
34+
pub(super) routes: Vec<EdgeRoutePlan>,
35+
pub(super) labels: Vec<EdgeLabelPlan>,
36+
}
37+
38+
#[derive(Clone, Debug)]
39+
pub(super) struct EdgePortPlan {
40+
pub(super) edge_idx: usize,
41+
pub(super) from: String,
42+
pub(super) to: String,
43+
pub(super) start_side: EdgeSide,
44+
pub(super) end_side: EdgeSide,
45+
pub(super) start_offset: f32,
46+
pub(super) end_offset: f32,
47+
pub(super) start_point: (f32, f32),
48+
pub(super) end_point: (f32, f32),
49+
pub(super) stub_len: f32,
50+
}
51+
52+
#[derive(Clone, Debug)]
53+
pub(super) struct EdgeBundlePlan {
54+
pub(super) key: BundleKey,
55+
pub(super) lanes: Vec<EdgeLanePlan>,
56+
}
57+
58+
#[derive(Clone, Debug)]
59+
pub(super) struct EdgeLanePlan {
60+
pub(super) edge_idx: usize,
61+
pub(super) lane_index: usize,
62+
pub(super) lane_count: usize,
63+
pub(super) base_offset: f32,
64+
pub(super) cross_edge_offset: f32,
65+
pub(super) effective_offset: f32,
66+
}
67+
68+
#[derive(Clone, Debug)]
69+
pub(super) struct EdgeRoutePlan {
70+
pub(super) edge_idx: usize,
71+
pub(super) points: Vec<(f32, f32)>,
72+
pub(super) approach_start: EdgeSide,
73+
pub(super) approach_end: EdgeSide,
74+
}
75+
76+
#[derive(Clone, Debug)]
77+
pub(super) struct EdgeLabelPlan {
78+
pub(super) edge_idx: usize,
79+
pub(super) label: Option<TextBlock>,
80+
pub(super) anchor: Option<(f32, f32)>,
81+
pub(super) reserved_center: Option<(f32, f32)>,
82+
}
83+
84+
impl FlowchartLayoutPlan {
85+
#[allow(clippy::too_many_arguments)]
86+
pub(super) fn from_current_pipeline(
87+
graph: &Graph,
88+
nodes: &BTreeMap<String, NodeLayout>,
89+
subgraphs: &[SubgraphLayout],
90+
edge_ports: &[EdgePortInfo],
91+
pair_counts: &HashMap<(String, String), usize>,
92+
pair_index: &[usize],
93+
cross_edge_offsets: &[f32],
94+
routed_points: &[Vec<(f32, f32)>],
95+
label_anchors: &[Option<(f32, f32)>],
96+
route_label_centers: &[Option<(f32, f32)>],
97+
edge_route_labels: &[Option<TextBlock>],
98+
config: &LayoutConfig,
99+
) -> Self {
100+
let mut ports = Vec::with_capacity(graph.edges.len());
101+
let mut bundle_lanes: HashMap<BundleKey, Vec<EdgeLanePlan>> = HashMap::new();
102+
let mut routes = Vec::with_capacity(graph.edges.len());
103+
let mut labels = Vec::with_capacity(graph.edges.len());
104+
105+
for (idx, edge) in graph.edges.iter().enumerate() {
106+
let Some(port_info) = edge_ports.get(idx).copied() else {
107+
continue;
108+
};
109+
let from_layout = nodes.get(&edge.from).expect("from node missing");
110+
let to_layout = nodes.get(&edge.to).expect("to node missing");
111+
let temp_from = from_layout.anchor_subgraph.and_then(|anchor_idx| {
112+
subgraphs
113+
.get(anchor_idx)
114+
.map(|sub| anchor_layout_for_edge(from_layout, sub, graph.direction, true))
115+
});
116+
let temp_to = to_layout.anchor_subgraph.and_then(|anchor_idx| {
117+
subgraphs
118+
.get(anchor_idx)
119+
.map(|sub| anchor_layout_for_edge(to_layout, sub, graph.direction, false))
120+
});
121+
let from = temp_from.as_ref().unwrap_or(from_layout);
122+
let to = temp_to.as_ref().unwrap_or(to_layout);
123+
let start_point =
124+
anchor_point_for_node(from, port_info.start_side, port_info.start_offset);
125+
let end_point = anchor_point_for_node(to, port_info.end_side, port_info.end_offset);
126+
let stub_len = port_stub_length(config, from, to);
127+
128+
ports.push(EdgePortPlan {
129+
edge_idx: idx,
130+
from: edge.from.clone(),
131+
to: edge.to.clone(),
132+
start_side: port_info.start_side,
133+
end_side: port_info.end_side,
134+
start_offset: port_info.start_offset,
135+
end_offset: port_info.end_offset,
136+
start_point,
137+
end_point,
138+
stub_len,
139+
});
140+
141+
let pair = edge_pair_key(edge);
142+
let total = *pair_counts.get(&pair).unwrap_or(&1);
143+
let lane_index = pair_index.get(idx).copied().unwrap_or_default();
144+
let base_offset = if total > 1 {
145+
(lane_index as f32 - (total as f32 - 1.0) / 2.0)
146+
* (config.node_spacing * MULTI_EDGE_OFFSET_RATIO)
147+
} else {
148+
0.0
149+
};
150+
let cross_edge_offset = cross_edge_offsets.get(idx).copied().unwrap_or_default();
151+
let raw_bias =
152+
(port_info.start_offset - port_info.end_offset) * FLOWCHART_PORT_ROUTE_BIAS_RATIO;
153+
let max_bias = (config.node_spacing * FLOWCHART_PORT_ROUTE_BIAS_MAX_RATIO).max(8.0);
154+
let effective_offset =
155+
base_offset + cross_edge_offset + raw_bias.clamp(-max_bias, max_bias);
156+
bundle_lanes
157+
.entry(BundleKey {
158+
a: pair.0,
159+
b: pair.1,
160+
})
161+
.or_default()
162+
.push(EdgeLanePlan {
163+
edge_idx: idx,
164+
lane_index,
165+
lane_count: total,
166+
base_offset,
167+
cross_edge_offset,
168+
effective_offset,
169+
});
170+
171+
routes.push(EdgeRoutePlan {
172+
edge_idx: idx,
173+
points: routed_points.get(idx).cloned().unwrap_or_default(),
174+
approach_start: port_info.start_side,
175+
approach_end: port_info.end_side,
176+
});
177+
178+
labels.push(EdgeLabelPlan {
179+
edge_idx: idx,
180+
label: edge_route_labels.get(idx).cloned().unwrap_or_default(),
181+
anchor: label_anchors.get(idx).copied().unwrap_or_default(),
182+
reserved_center: route_label_centers.get(idx).copied().unwrap_or_default(),
183+
});
184+
}
185+
186+
let mut bundles: Vec<EdgeBundlePlan> = bundle_lanes
187+
.into_iter()
188+
.map(|(key, mut lanes)| {
189+
lanes.sort_by_key(|lane| lane.edge_idx);
190+
EdgeBundlePlan { key, lanes }
191+
})
192+
.collect();
193+
bundles.sort_by(|a, b| a.key.a.cmp(&b.key.a).then_with(|| a.key.b.cmp(&b.key.b)));
194+
195+
Self {
196+
edge_count: graph.edges.len(),
197+
ports,
198+
bundles,
199+
routes,
200+
labels,
201+
}
202+
}
203+
204+
pub(super) fn is_consistent(&self) -> bool {
205+
self.ports.len() == self.edge_count
206+
&& self.routes.len() == self.edge_count
207+
&& self.labels.len() == self.edge_count
208+
&& self
209+
.bundles
210+
.iter()
211+
.flat_map(|bundle| bundle.lanes.iter())
212+
.count()
213+
== self.edge_count
214+
}
215+
}
216+
217+
#[cfg(test)]
218+
mod tests {
219+
use std::collections::{BTreeMap, HashMap};
220+
221+
use crate::config::LayoutConfig;
222+
use crate::ir::{Direction, Edge, EdgeStyle, Graph, NodeShape};
223+
use crate::layout::flowchart::plan::FlowchartLayoutPlan;
224+
use crate::layout::routing::{EdgePortInfo, EdgeSide};
225+
use crate::layout::{NodeLayout, TextBlock};
226+
227+
fn node(id: &str, x: f32, y: f32) -> NodeLayout {
228+
NodeLayout {
229+
id: id.to_string(),
230+
x,
231+
y,
232+
width: 80.0,
233+
height: 40.0,
234+
label: TextBlock {
235+
lines: vec![id.to_string()],
236+
width: 10.0,
237+
height: 10.0,
238+
},
239+
shape: NodeShape::Rectangle,
240+
style: Default::default(),
241+
link: None,
242+
anchor_subgraph: None,
243+
hidden: false,
244+
icon: None,
245+
}
246+
}
247+
248+
fn edge(from: &str, to: &str) -> Edge {
249+
Edge {
250+
from: from.to_string(),
251+
to: to.to_string(),
252+
label: Some("label".to_string()),
253+
start_label: None,
254+
end_label: None,
255+
directed: true,
256+
arrow_start: false,
257+
arrow_end: true,
258+
arrow_start_kind: None,
259+
arrow_end_kind: None,
260+
start_decoration: None,
261+
end_decoration: None,
262+
style: EdgeStyle::Solid,
263+
}
264+
}
265+
266+
#[test]
267+
fn plan_groups_parallel_edges_into_bundle_lanes() {
268+
let mut graph = Graph::new();
269+
graph.direction = Direction::LeftRight;
270+
graph.edges.push(edge("A", "B"));
271+
graph.edges.push(edge("A", "B"));
272+
273+
let mut nodes = BTreeMap::new();
274+
nodes.insert("A".to_string(), node("A", 0.0, 0.0));
275+
nodes.insert("B".to_string(), node("B", 160.0, 0.0));
276+
277+
let ports = vec![
278+
EdgePortInfo {
279+
start_side: EdgeSide::Right,
280+
end_side: EdgeSide::Left,
281+
start_offset: -8.0,
282+
end_offset: -8.0,
283+
},
284+
EdgePortInfo {
285+
start_side: EdgeSide::Right,
286+
end_side: EdgeSide::Left,
287+
start_offset: 8.0,
288+
end_offset: 8.0,
289+
},
290+
];
291+
let mut pair_counts = HashMap::new();
292+
pair_counts.insert(("A".to_string(), "B".to_string()), 2);
293+
let pair_index = vec![0, 1];
294+
let routed_points = vec![
295+
vec![(80.0, 12.0), (160.0, 12.0)],
296+
vec![(80.0, 28.0), (160.0, 28.0)],
297+
];
298+
let labels = vec![None, None];
299+
let plan = FlowchartLayoutPlan::from_current_pipeline(
300+
&graph,
301+
&nodes,
302+
&[],
303+
&ports,
304+
&pair_counts,
305+
&pair_index,
306+
&[0.0, 0.0],
307+
&routed_points,
308+
&[Some((120.0, 12.0)), Some((120.0, 28.0))],
309+
&[None, None],
310+
&labels,
311+
&LayoutConfig::default(),
312+
);
313+
314+
assert!(plan.is_consistent());
315+
assert_eq!(plan.bundles.len(), 1);
316+
assert_eq!(plan.bundles[0].lanes.len(), 2);
317+
assert!(
318+
plan.bundles[0].lanes[0].effective_offset < plan.bundles[0].lanes[1].effective_offset
319+
);
320+
}
321+
}

src/layout/flowchart/route_labels.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,13 @@ pub(super) fn should_route_labels_via(graph: &Graph, nodes: &BTreeMap<String, No
5454
!has_label_dummies && graph.kind != DiagramKind::Er
5555
}
5656

57+
pub(super) fn route_label_centers(plans: &[Option<RouteLabelPlan>]) -> Vec<Option<(f32, f32)>> {
58+
plans
59+
.iter()
60+
.map(|plan| plan.as_ref().map(|plan| plan.center))
61+
.collect()
62+
}
63+
5764
fn flowchart_label_needs_reserved_route_gap(label: &TextBlock, config: &LayoutConfig) -> bool {
5865
if label.lines.len() > 1 {
5966
return true;

0 commit comments

Comments
 (0)