use crate::layout::{intrinsic_size, GAP_L};
use crate::model::{Direction, Edge, EdgeKind, Graph};
use crate::scene::{
flip_scene, grow_scene, parallel_offsets, scene_sized, shift_scene, Bbox, Scene, SceneEdge,
MARGIN,
};
const MIN_RUN: usize = 5;
const BAND_GAP: f64 = 44.0;
const TURN_DROP: f64 = 36.0;
const LANE: f64 = 14.0;
const TURN_PAD: f64 = TURN_DROP;
const BREAK_PENALTY: f64 = 60.0 * 60.0;
const FOLD_LABEL_PENALTY: f64 = 40.0 * 40.0;
const WIDE_TURN: f64 = 160.0;
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct CompactOptions {
pub max_extent: f64,
pub min_run: usize,
pub band_gap: f64,
}
impl CompactOptions {
pub fn for_extent(max_extent: f64) -> CompactOptions {
CompactOptions {
max_extent,
min_run: MIN_RUN,
band_gap: BAND_GAP,
}
}
pub fn fit(viewport_w: f64, viewport_h: f64, dir: Direction) -> CompactOptions {
match dir {
Direction::TD | Direction::BT => CompactOptions::for_extent(viewport_h),
Direction::LR | Direction::RL => CompactOptions::for_extent(viewport_w),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum FoldSkip {
AlreadyFits,
NoBenefit,
HasSubgraphs,
NotLinear,
MultipleComponents,
TooShort,
NodeTooLong,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct CompactScene {
pub scene: Scene,
pub skipped: Option<FoldSkip>,
pub bands: usize,
}
pub fn scene_compact(g: &Graph, opts: &CompactOptions) -> CompactScene {
let sizes: Vec<(f64, f64)> = g.nodes.iter().map(intrinsic_size).collect();
scene_compact_sized(g, &sizes, opts)
}
pub fn scene_compact_sized(g: &Graph, sizes: &[(f64, f64)], opts: &CompactOptions) -> CompactScene {
let base = scene_sized(g, sizes);
let horizontal = matches!(g.direction, Direction::LR | Direction::RL);
let flow_extent = if horizontal { base.width } else { base.height };
let skip = |scene: Scene, why: FoldSkip| CompactScene {
scene,
skipped: Some(why),
bands: 1,
};
if flow_extent <= opts.max_extent {
return skip(base, FoldSkip::AlreadyFits);
}
let run = match chain_run(g) {
Ok(r) => r,
Err(why) => return skip(base, why),
};
if run.len() < opts.min_run.max(2) {
return skip(base, FoldSkip::TooShort);
}
let ext: Vec<f64> = run
.iter()
.map(|&v| if horizontal { sizes[v].0 } else { sizes[v].1 })
.collect();
let mut pos_of = vec![usize::MAX; g.nodes.len()];
for (i, &v) in run.iter().enumerate() {
pos_of[v] = i;
}
let mut labelled_gap = vec![false; run.len().saturating_sub(1)];
let mut par_at = vec![0usize; run.len().saturating_sub(1)];
for e in &g.edges {
if e.from == e.to || matches!(e.kind, EdgeKind::Invisible) {
continue;
}
let (pa, pb) = (pos_of[e.from], pos_of[e.to]);
if pa == usize::MAX || pb == usize::MAX {
continue;
}
let lo = pa.min(pb);
if lo + 1 == pa.max(pb) {
par_at[lo] += 1;
if e.label.is_some() {
labelled_gap[lo] = true;
}
}
}
let bands = match fold_points(&ext, &labelled_gap, &par_at, opts.max_extent) {
Ok(b) => b,
Err(why) => return skip(base, why),
};
if bands.len() <= 1 {
return skip(base, FoldSkip::NoBenefit);
}
let scene = compose(g, sizes, &run, &pos_of, &bands, horizontal, opts);
CompactScene {
bands: bands.len(),
scene,
skipped: None,
}
}
pub fn render_compact(g: &Graph, opts: &CompactOptions) -> String {
render_compact_titled(g, opts, "Flowchart diagram")
}
pub fn render_compact_titled(g: &Graph, opts: &CompactOptions, title: &str) -> String {
crate::scene::to_svg_titled(&scene_compact(g, opts).scene, title)
}
fn chain_run(g: &Graph) -> Result<Vec<usize>, FoldSkip> {
if !g.subgraphs.is_empty() || !g.sub_edges.is_empty() {
return Err(FoldSkip::HasSubgraphs);
}
let n = g.nodes.len();
if n < 2 {
return Err(FoldSkip::TooShort);
}
let mut next: Vec<Option<usize>> = vec![None; n];
let mut prev: Vec<Option<usize>> = vec![None; n];
for e in &g.edges {
if e.from == e.to || matches!(e.kind, EdgeKind::Invisible) {
continue;
}
match next[e.from] {
None => next[e.from] = Some(e.to),
Some(t) if t == e.to => {} Some(_) => return Err(FoldSkip::NotLinear),
}
match prev[e.to] {
None => prev[e.to] = Some(e.from),
Some(f) if f == e.from => {}
Some(_) => return Err(FoldSkip::NotLinear),
}
}
let starts: Vec<usize> = (0..n).filter(|&v| prev[v].is_none()).collect();
if starts.len() != 1 {
return Err(if starts.is_empty() {
FoldSkip::NotLinear
} else {
FoldSkip::MultipleComponents
});
}
let mut run = Vec::with_capacity(n);
let mut cur = Some(starts[0]);
while let Some(v) = cur {
run.push(v);
cur = next[v];
if run.len() > n {
return Err(FoldSkip::NotLinear);
}
}
if run.len() != n {
return Err(FoldSkip::MultipleComponents);
}
Ok(run)
}
fn fold_points(
ext: &[f64],
labelled_gap: &[bool],
par_at: &[usize],
max_extent: f64,
) -> Result<Vec<(usize, usize)>, FoldSkip> {
let n = ext.len();
let mut prefix = vec![0.0f64; n + 1];
for i in 0..n {
prefix[i + 1] = prefix[i] + ext[i];
}
let turn_res = |h: usize| -> f64 { TURN_PAD + par_at[h].saturating_sub(1) as f64 * LANE };
let band_len = |j: usize, i: usize| -> f64 {
let mut len = prefix[i + 1] - prefix[j] + GAP_L * (i - j) as f64;
if j > 0 {
len += turn_res(j - 1);
}
if i + 1 < n {
len += turn_res(i);
}
len
};
for (i, _) in ext.iter().enumerate() {
if band_len(i, i) > max_extent {
return Err(FoldSkip::NodeTooLong);
}
}
let mut best: Vec<(f64, usize)> = vec![(f64::INFINITY, 0); n + 1];
best[0] = (0.0, 0);
for i in 1..=n {
for j in (0..i).rev() {
let len = band_len(j, i - 1);
if len > max_extent {
continue;
}
let slack = max_extent - len;
let mut cost = best[j].0 + slack * slack;
if j > 0 {
cost += BREAK_PENALTY;
if labelled_gap[j - 1] {
cost += FOLD_LABEL_PENALTY;
}
}
if cost < best[i].0 {
best[i] = (cost, j);
}
}
if best[i].0.is_infinite() {
return Err(FoldSkip::NodeTooLong); }
}
let mut bands = Vec::new();
let mut end = n;
while end > 0 {
let start = best[end].1;
bands.push((start, end - 1));
end = start;
}
bands.reverse();
Ok(bands)
}
fn compose(
g: &Graph,
sizes: &[(f64, f64)],
run: &[usize],
pos_of: &[usize],
bands: &[(usize, usize)],
horizontal: bool,
opts: &CompactOptions,
) -> Scene {
let nb = bands.len();
let band_of = |v: usize| -> usize {
let p = pos_of[v];
bands.iter().position(|&(s, e)| p >= s && p <= e).unwrap()
};
let mut edge_map: Vec<Option<(usize, usize)>> = vec![None; g.edges.len()];
let mut scenes: Vec<Scene> = Vec::with_capacity(nb);
for (k, &(s, e)) in bands.iter().enumerate() {
let slice: Vec<usize> = run[s..=e].to_vec();
let mut local = vec![usize::MAX; g.nodes.len()];
let mut bg = Graph::default();
bg.direction = g.direction;
let mut bsizes = Vec::with_capacity(slice.len());
for (li, &v) in slice.iter().enumerate() {
local[v] = li;
bg.nodes.push(g.nodes[v].clone());
bsizes.push(sizes[v]);
}
for (ei, edge) in g.edges.iter().enumerate() {
if local[edge.from] != usize::MAX && local[edge.to] != usize::MAX {
edge_map[ei] = Some((k, bg.edges.len()));
bg.edges.push(Edge {
from: local[edge.from],
to: local[edge.to],
label: edge.label.clone(),
kind: edge.kind,
});
}
}
scenes.push(scene_sized(&bg, &bsizes));
}
let mut off = 0.0f64;
for (k, sc) in scenes.iter_mut().enumerate() {
if k % 2 == 1 {
let extent = if horizontal { sc.width } else { sc.height };
flip_scene(sc, extent, horizontal);
}
let (dx, dy) = if horizontal { (0.0, off) } else { (off, 0.0) };
shift_scene(sc, dx, dy);
let breadth = if horizontal { sc.height } else { sc.width };
off += breadth - 2.0 * MARGIN + opts.band_gap;
}
let nodes: Vec<crate::scene::SceneNode> = (0..g.nodes.len())
.map(|v| {
let k = band_of(v);
let (s, _) = bands[k];
scenes[k].nodes[pos_of[v] - s].clone()
})
.collect();
let offs = parallel_offsets(g);
let mut gutter_lane = vec![0usize; nb];
let flow = |p: &crate::scene::SceneNode| if horizontal { p.x } else { p.y };
let global_top = nodes
.iter()
.map(&flow)
.fold(f64::INFINITY, f64::min);
let global_bot = nodes
.iter()
.map(&flow)
.fold(f64::NEG_INFINITY, f64::max);
let edges: Vec<SceneEdge> = g
.edges
.iter()
.enumerate()
.map(|(ei, e)| {
if let Some((k, bei)) = edge_map[ei] {
return scenes[k].edges[bei].clone();
}
let (a, b) = (&nodes[e.from], &nodes[e.to]);
if matches!(e.kind, EdgeKind::Invisible) {
return SceneEdge {
from: a.id.clone(),
to: b.id.clone(),
bezier: [(a.x, a.y), (a.x, a.y), (b.x, b.y), (b.x, b.y)],
waypoints: Vec::new(),
kind: e.kind,
label: None,
};
}
let gutter = band_of(e.from).min(band_of(e.to));
let lane = gutter_lane[gutter];
gutter_lane[gutter] += 1;
fold_connector(e, a, b, horizontal, lane, offs[ei], global_top, global_bot)
})
.collect();
let mut sc = Scene::empty(0.0, 0.0);
sc.nodes = nodes;
sc.edges = edges;
let mut bb = Bbox::new();
grow_scene(&mut bb, &sc.nodes, &sc.edges, &sc.clusters);
let (minx, maxx, miny, maxy) = bb.finish();
shift_scene(&mut sc, MARGIN - minx, MARGIN - miny);
sc.width = (maxx - minx) + 2.0 * MARGIN;
sc.height = (maxy - miny) + 2.0 * MARGIN;
sc
}
#[allow(clippy::too_many_arguments)] fn fold_connector(
e: &Edge,
a: &crate::scene::SceneNode,
b: &crate::scene::SceneNode,
horizontal: bool,
lane: usize,
off: f64,
global_top: f64,
global_bot: f64,
) -> SceneEdge {
type Axis = fn(&crate::scene::SceneNode) -> f64;
let (fl, br): (Axis, Axis) = if horizontal {
(|n| n.x, |n| n.y)
} else {
(|n| n.y, |n| n.x)
};
let mk = |flow: f64, breadth: f64| -> (f64, f64) {
if horizontal {
(flow, breadth)
} else {
(breadth, flow)
}
};
let to_top = fl(a).min(fl(b)) - global_top;
let to_bot = global_bot - fl(a).max(fl(b));
let bottom = to_bot <= to_top;
let half_flow = |n: &crate::scene::SceneNode| if horizontal { n.w / 2.0 } else { n.h / 2.0 };
let apex = if bottom {
fl(a).max(fl(b)) + half_flow(a).max(half_flow(b)) + TURN_DROP + lane as f64 * LANE
} else {
fl(a).min(fl(b)) - half_flow(a).max(half_flow(b)) - TURN_DROP - lane as f64 * LANE
};
let exit = |n: &crate::scene::SceneNode| -> (f64, f64) {
let s = if bottom { 1.0 } else { -1.0 };
mk(fl(n) + s * half_flow(n), br(n) + off)
};
let p0 = exit(a);
let p3 = exit(b);
let mut wps = Vec::with_capacity(5);
wps.push(p0);
wps.push(mk(apex, br(a)));
let wide = (br(a) - br(b)).abs() > WIDE_TURN;
if wide {
wps.push(mk(apex, (br(a) + br(b)) / 2.0));
}
wps.push(mk(apex, br(b)));
wps.push(p3);
let label = e.label.as_ref().map(|l| {
(
l.clone(),
mk(apex, (br(a) + br(b)) / 2.0),
crate::layout::text_width(l) + 14.0,
)
});
SceneEdge {
from: a.id.clone(),
to: b.id.clone(),
bezier: [p0, mk(apex, br(a)), mk(apex, br(b)), p3],
waypoints: wps,
kind: e.kind,
label,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::parse;
fn chain(n: usize) -> String {
let mut s = String::from("flowchart TD\n");
for i in 0..n - 1 {
s.push_str(&format!("N{} --> N{}\n", i, i + 1));
}
s
}
#[test]
fn long_chain_folds_into_balanced_bands_within_budget() {
let g = parse(&chain(12)).unwrap();
let c = scene_compact(&g, &CompactOptions::for_extent(420.0));
assert!(c.skipped.is_none(), "must fold: {:?}", c.skipped);
assert!(c.bands >= 2);
assert_eq!(c.scene.nodes.len(), g.nodes.len());
assert_eq!(c.scene.edges.len(), g.edges.len());
for (sn, n) in c.scene.nodes.iter().zip(&g.nodes) {
assert_eq!(sn.id, n.id);
}
assert!(
c.scene.height <= 420.0 + 2.0 * (TURN_DROP + MARGIN) + 40.0,
"flow extent {} blew the budget",
c.scene.height
);
let plain = crate::scene::scene(&g);
assert!(
c.scene.height < plain.height / 2.0,
"folded {} vs plain {}",
c.scene.height,
plain.height
);
assert!(c.scene.width > plain.width, "bands spread along breadth");
let folded = c.scene.edges.iter().filter(|e| e.waypoints.len() >= 4).count();
assert!(folded >= c.bands - 1);
let svg = crate::scene::to_svg(&c.scene);
assert!(!svg.contains("NaN"));
}
#[test]
fn every_direction_folds_on_its_own_flow_axis() {
for (dir, taller_than_wide) in
[("TD", false), ("BT", false), ("LR", true), ("RL", true)]
{
let src = chain(12).replace("flowchart TD", &format!("flowchart {dir}"));
let g = parse(&src).unwrap();
let c = scene_compact(&g, &CompactOptions::for_extent(420.0));
assert!(c.skipped.is_none(), "{dir} must fold");
assert!(c.bands >= 2, "{dir}");
let plain = crate::scene::scene(&g);
let (flow, plain_flow) = if taller_than_wide {
(c.scene.width, plain.width) } else {
(c.scene.height, plain.height)
};
assert!(
flow < plain_flow / 2.0,
"{dir}: folded flow {flow} vs plain {plain_flow}"
);
let svg = crate::scene::to_svg(&c.scene);
assert!(!svg.contains("NaN"), "{dir}");
}
}
#[test]
fn refusals_return_byte_identical_plain_scenes() {
let same = |src: &str, why: FoldSkip, max: f64| {
let g = parse(src).unwrap();
let c = scene_compact(&g, &CompactOptions::for_extent(max));
assert_eq!(c.skipped, Some(why), "{src:?}");
assert_eq!(c.bands, 1);
let plain = crate::scene::scene(&g);
assert_eq!(
crate::scene::to_svg(&c.scene),
crate::scene::to_svg(&plain),
"refusal must be byte-identical for {src:?}"
);
};
same(&chain(12), FoldSkip::AlreadyFits, 100_000.0);
same("flowchart TD\nA-->B\nA-->C\nB-->D\nC-->D\nD-->E\nE-->F\nF-->G\nG-->H\nH-->I\nI-->J", FoldSkip::NotLinear, 100.0);
same(
"flowchart TD\nsubgraph S\nA-->B\nend\nB-->C\nC-->D\nD-->E\nE-->F\nF-->G",
FoldSkip::HasSubgraphs,
100.0,
);
same(
"flowchart TD\nA-->B\nB-->C\nC-->D\nD-->E\nX-->Y\nY-->Z",
FoldSkip::MultipleComponents,
100.0,
);
same("flowchart TD\nA-->B\nB-->C\nC-->D\nD-->E\nE-->A", FoldSkip::NotLinear, 100.0);
same("flowchart TD\nA-->B\nB-->C", FoldSkip::TooShort, 10.0);
}
#[test]
fn labels_ride_the_crossbar_and_drag_reroutes_locally() {
let mut src = chain(12);
src = src.replace("N5 --> N6", "N5 -->|hop| N6");
let g = parse(&src).unwrap();
let c = scene_compact(&g, &CompactOptions::for_extent(420.0));
assert!(c.skipped.is_none());
let labelled = c
.scene
.edges
.iter()
.filter(|e| e.label.is_some())
.count();
assert_eq!(labelled, 1);
let auto: Vec<(f64, f64)> = c.scene.nodes.iter().map(|n| (n.x, n.y)).collect();
let mut dragged = auto.clone();
dragged[0].0 += 80.0;
let r = crate::scene::route_partial(&g, &dragged, &c.scene, &auto);
assert_eq!(r.edges.len(), c.scene.edges.len());
for (ri, ci) in r.edges.iter().zip(c.scene.edges.iter()).skip(2) {
assert_eq!(ri.waypoints, ci.waypoints, "untouched edges keep fold turns");
}
}
#[test]
fn budget_holds_with_parallel_edges_across_a_fold() {
let mut src = chain(12);
for _ in 0..4 {
src.push_str("N5 --> N6\n");
}
let g = parse(&src).unwrap();
let budget = 360.0;
let c = scene_compact(&g, &CompactOptions::for_extent(budget));
assert!(c.skipped.is_none(), "must fold: {:?}", c.skipped);
assert!(
c.scene.height <= budget + 2.0 * MARGIN + 1.0,
"parallel-fold flow extent {} exceeded budget {}",
c.scene.height,
budget
);
assert_eq!(c.scene.edges.len(), g.edges.len(), "edges stay 1:1");
assert!(!crate::scene::to_svg(&c.scene).contains("NaN"));
}
#[test]
fn lr_fold_connectors_leave_the_flow_face_not_the_breadth_face() {
let src = chain(12).replace("flowchart TD", "flowchart LR");
let g = parse(&src).unwrap();
let c = scene_compact(&g, &CompactOptions::for_extent(420.0));
assert!(c.skipped.is_none(), "LR must fold");
let by_id: std::collections::HashMap<&str, &crate::scene::SceneNode> =
c.scene.nodes.iter().map(|n| (n.id.as_str(), n)).collect();
let folded = c
.scene
.edges
.iter()
.filter(|e| e.waypoints.len() >= 4)
.collect::<Vec<_>>();
assert!(!folded.is_empty(), "expected fold connectors");
for e in folded {
let a = by_id[e.from.as_str()];
let p0 = e.waypoints[0];
assert!(
(p0.1 - a.y).abs() <= a.h / 2.0 + 0.5,
"LR exit left the breadth (y) face: node y={}, exit y={}",
a.y,
p0.1
);
assert!(
(p0.0 - a.x).abs() >= a.w / 2.0 - 0.5,
"LR exit not on the flow (x) face: node x={}, exit x={}",
a.x,
p0.0
);
}
}
}