diff --git a/src/main/java/com/yunhai/flowcenter/service/DiagramLayoutService.java b/src/main/java/com/yunhai/flowcenter/service/DiagramLayoutService.java index 8b395a6..5249598 100644 --- a/src/main/java/com/yunhai/flowcenter/service/DiagramLayoutService.java +++ b/src/main/java/com/yunhai/flowcenter/service/DiagramLayoutService.java @@ -111,6 +111,10 @@ public class DiagramLayoutService { // 并行分支组以 SPLIT 竖直中线为轴左右居中(内层组先处理,最后整体推回左边界 0) centerParallelGroups(nodes, out, box); + // 条件分支分列(2026-10-12):CONDITION 多出口各分支水平分列、隐式汇合点吸附回条件节点 + // 中线——修复不等长/空分支被拓扑分层压成单列、跳层边直穿中间节点且出口文案错位的问题 + spreadConditionBranches(nodes, out, box); + // 归零(2026-10-03 三视图统一新增):全体平移使 minX=minY=0,positions 消费端无需再算包围盒 if (!box.isEmpty()) { int minX = Integer.MAX_VALUE; @@ -129,6 +133,139 @@ public class DiagramLayoutService { return box; } + /** + * 条件分支分列(2026-10-12):CONDITION 多出口按 SPLIT 同款"分支列"处理——第 i 个出口的 + * 分支成员整体平移到「条件节点竖直中线 + (i-(n-1)/2)·列距」的列中心,出口汇合点(各分支 + * 可达集交集的 BFS 最早节点,即隐式 JOIN)不移动、留在中线上。 + *

+ * 背景:等长分支天然被拓扑分层排进同一层横排(旧测试 conditionBranches 同层用例); + * 不等长/空分支(如"校长室→抄送→审批 / 其他→直达结束")各层退化单列,跳层出口边变成 + * 穿过中间节点的直线、出口文案叠在链中间。本 pass 对已正常的等长场景是恒等变换 + * (列目标=当前居中位),对不等长场景把各分支拉回各自列。 + * 嵌套/顺序多个条件节点按定义序处理:外层先整体平移分支(内层条件节点随之移动), + * 内层处理时以移动后的自身中线为轴再展开,位移可组合。 + */ + private void spreadConditionBranches(List nodes, Map> out, + Map box) { + int colSpan = SLOT_W + GAP_X; + for (FlowModels.NodeVo cond : nodes) { + if (!FlowDefModel.TYPE_CONDITION.equals(cond.getType()) || !box.containsKey(cond.getId())) { + continue; + } + List heads = out.getOrDefault(cond.getId(), new ArrayList<>()); + if (heads.size() < 2) { + continue; + } + // 各分支可达集(含汇合点),用于定位隐式 JOIN + List> reachSets = new ArrayList<>(); + for (String head : heads) { + java.util.Set set = new java.util.HashSet<>(); + List q = new ArrayList<>(); + q.add(head); + while (!q.isEmpty()) { + String cur = q.remove(0); + if (!box.containsKey(cur) || set.contains(cur)) { + continue; + } + set.add(cur); + q.addAll(out.getOrDefault(cur, new ArrayList<>())); + } + reachSets.add(set); + } + java.util.Set common = reachSets.isEmpty() ? new java.util.HashSet<>() : reachSets.get(0); + for (java.util.Set set : reachSets) { + common = new java.util.HashSet<>(common); + common.retainAll(set); + } + if (common.isEmpty()) { + continue; + } + // 隐式 JOIN:BFS 序最早(近者优先,跳过条件节点自身) + Map order = new HashMap<>(); + order.put(cond.getId(), 0); + List bfs = new ArrayList<>(); + bfs.add(cond.getId()); + while (!bfs.isEmpty()) { + String cur = bfs.remove(0); + for (String to : out.getOrDefault(cur, new ArrayList<>())) { + if (!order.containsKey(to)) { + order.put(to, order.size()); + bfs.add(to); + } + } + } + String join = null; + int joinOrder = Integer.MAX_VALUE; + for (String id : order.keySet()) { + if (id.equals(cond.getId()) || !common.contains(id)) { + continue; + } + int o = order.get(id); + if (o < joinOrder) { + joinOrder = o; + join = id; + } + } + if (join == null) { + continue; + } + int[] cb = box.get(cond.getId()); + int axisX = cb[0] + cb[2] / 2; + int n = heads.size(); + java.util.Set forkClaimed = new java.util.HashSet<>(); + for (int i = 0; i < n; i++) { + // 分支成员:从 head 出发、不穿隐式 JOIN、同一汇合点内不重复认领(菱形交叉兜底) + List members = new ArrayList<>(); + List q = new ArrayList<>(); + q.add(heads.get(i)); + while (!q.isEmpty()) { + String cur = q.remove(0); + if (cur.equals(join) || !box.containsKey(cur) || forkClaimed.contains(cur)) { + continue; + } + forkClaimed.add(cur); + members.add(cur); + q.addAll(out.getOrDefault(cur, new ArrayList<>())); + } + if (members.isEmpty()) { + continue; + } + // 链形分支(每层至多一个成员)逐节点对齐到分支列中心——多层分支整块平移保不了同列; + // 非链形(分支内嵌并行等)回退整块平移,保住内部结构不塌列 + boolean chain = true; + java.util.Set rows = new java.util.HashSet<>(); + for (String m : members) { + if (!rows.add(box.get(m)[1])) { + chain = false; + break; + } + } + int left = Integer.MAX_VALUE; + int right = Integer.MIN_VALUE; + for (String m : members) { + int[] b = box.get(m); + left = Math.min(left, b[0]); + right = Math.max(right, b[0] + b[2]); + } + // (i·列距) − ((n−1)·列距)/2:偶数出口也有精确的 ±列距/2 偏移(colSpan 恒偶,整除无误差) + int target = axisX + i * colSpan - (n - 1) * colSpan / 2; + if (chain) { + for (String m : members) { + int[] b = box.get(m); + b[0] += target - (b[0] + b[2] / 2); + } + } else { + int delta = target - (left + right) / 2; + if (delta != 0) { + for (String m : members) { + box.get(m)[0] += delta; + } + } + } + } + } + } + /** * 并行分支组水平居中:分支不再从 SPLIT 行向右堆叠,而是以 SPLIT 竖直中线为轴左右对称展开。 * SPLIT↔JOIN 配对沿用编辑器口径(各出边可达集求交,取 BFS 序最早的 PARALLEL_JOIN); diff --git a/src/test/java/com/yunhai/flowcenter/service/DiagramLayoutServiceTest.java b/src/test/java/com/yunhai/flowcenter/service/DiagramLayoutServiceTest.java index 3a45304..c6223b6 100644 --- a/src/test/java/com/yunhai/flowcenter/service/DiagramLayoutServiceTest.java +++ b/src/test/java/com/yunhai/flowcenter/service/DiagramLayoutServiceTest.java @@ -183,6 +183,60 @@ class DiagramLayoutServiceTest { } } + @Test + void conditionUnequalBranches_forkIntoColumns_joinStaysOnAxis() { + // def-32 同款(2026-10-12 用户报错场景):校长室线=抄送→科室审批(两层),其他线=直达结束; + // 修复前各层退化单列、跳层出口边直穿中间节点。修复后:分支成员独占一列,隐式汇合点(end) + // 留在条件节点中线上,cond→end 直落边在自己的车道里(不与分支列水平相交)。 + List nodes = new ArrayList<>(Arrays.asList( + node("start", FlowDefModel.TYPE_START), + node("n1", FlowDefModel.TYPE_APPROVAL), + node("cond", FlowDefModel.TYPE_CONDITION), + node("cc1", FlowDefModel.TYPE_NOTIFY), + node("n3", FlowDefModel.TYPE_APPROVAL), + node("end", FlowDefModel.TYPE_END))); + Map box = layout(nodes, + edge("start", "n1"), edge("n1", "cond"), + edge("cond", "cc1"), edge("cond", "end"), + edge("cc1", "n3"), edge("n3", "end")); + assertEquals(cx(box.get("cc1")), cx(box.get("n3")), 1, "校长室线成员应同列"); + assertEquals(cx(box.get("cond")), cx(box.get("end")), 1, "隐式汇合点应留在条件节点中线"); + assertEquals((DiagramLayoutService.SLOT_W + DiagramLayoutService.GAP_X) / 2, + Math.abs(cx(box.get("cond")) - cx(box.get("cc1"))), 1, "空分支时非空分支占 ±列距/2 位(与等长分支对称口径一致)"); + // 直落边车道无遮挡:cc1/n3 的盒体不得横跨 cond→end 垂直线(x=条件节点中线) + int axis = cx(box.get("cond")); + for (String m : new String[]{"cc1", "n3"}) { + int[] b = box.get(m); + assertTrue(b[0] + b[2] <= axis || b[0] >= axis, m + " 盒体跨中线会被直落边穿过"); + } + for (int[] b : box.values()) { + assertTrue(b[0] >= 0 && b[1] >= 0, "归零后不得有负坐标"); + } + } + + @Test + void conditionBothBranchesNonEmpty_multiLevelSideBySide() { + // 两分支均多层且长度不等:A 线两层、B 线一层——各自成列、汇合点回中线 + List nodes = new ArrayList<>(Arrays.asList( + node("start", FlowDefModel.TYPE_START), + node("cond", FlowDefModel.TYPE_CONDITION), + node("a1", FlowDefModel.TYPE_APPROVAL), + node("a2", FlowDefModel.TYPE_APPROVAL), + node("b1", FlowDefModel.TYPE_APPROVAL), + node("end", FlowDefModel.TYPE_END))); + Map box = layout(nodes, + edge("start", "cond"), + edge("cond", "a1"), edge("cond", "b1"), + edge("a1", "a2"), edge("a2", "end"), edge("b1", "end")); + assertEquals(cx(box.get("a1")), cx(box.get("a2")), 1, "A 线成员同列"); + assertEquals(cx(box.get("cond")), cx(box.get("end")), 1, "汇合点在中线"); + // A/B 两列分居中线两侧(±列距/2 对称) + assertTrue((cx(box.get("a1")) < cx(box.get("cond"))) != (cx(box.get("b1")) < cx(box.get("cond"))), + "两分支应分居中线两侧"); + assertEquals(DiagramLayoutService.SLOT_W + DiagramLayoutService.GAP_X, + Math.abs(cx(box.get("b1")) - cx(box.get("a1"))), 1, "两分支列距"); + } + @Test void cycle_defensive_fallbackLevelStillPositionsAllNodes() { List nodes = new ArrayList<>(Arrays.asList(