From 6f68e1ebe72b9adeecc714220d0c2e0511fd5d2d Mon Sep 17 00:00:00 2001 From: chenxing Date: Mon, 12 Oct 2026 08:59:05 +0800 Subject: [PATCH] =?UTF-8?q?fix(diagram):=20CONDITION=20=E5=A4=9A=E5=87=BA?= =?UTF-8?q?=E5=8F=A3=E5=88=86=E6=94=AF=E5=88=86=E5=88=97=E2=80=94=E2=80=94?= =?UTF-8?q?=E4=B8=8D=E7=AD=89=E9=95=BF/=E7=A9=BA=E5=88=86=E6=94=AF?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E8=A2=AB=E6=8B=93=E6=89=91=E5=88=86=E5=B1=82?= =?UTF-8?q?=E5=8E=8B=E6=88=90=E5=8D=95=E5=88=97=E7=9B=B4=E7=A9=BF=EF=BC=88?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E5=AE=9E=E6=B5=8B=20def-32=20=E6=88=96?= =?UTF-8?q?=E7=AD=BE=E6=B5=81=E7=A8=8B=E6=8A=A5=E9=94=99=EF=BC=89=E3=80=82?= =?UTF-8?q?spreadConditionBranches=20=E5=90=8E=E5=A4=84=E7=90=86=EF=BC=9A?= =?UTF-8?q?=E5=90=84=E5=87=BA=E5=8F=A3=E5=88=86=E6=94=AF=E6=8C=89=E5=88=97?= =?UTF-8?q?=E5=AF=B9=E7=A7=B0=E5=B1=95=E5=BC=80=EF=BC=88=E9=93=BE=E5=BD=A2?= =?UTF-8?q?=E5=88=86=E6=94=AF=E9=80=90=E8=8A=82=E7=82=B9=E5=AF=B9=E5=88=97?= =?UTF-8?q?=E4=B8=AD=E5=BF=83=EF=BC=8C=E5=86=85=E5=B5=8C=E5=B9=B6=E8=A1=8C?= =?UTF-8?q?=E5=9B=9E=E9=80=80=E6=95=B4=E5=9D=97=E5=B9=B3=E7=A7=BB=EF=BC=89?= =?UTF-8?q?=EF=BC=8C=E9=9A=90=E5=BC=8F=E6=B1=87=E5=90=88=E7=82=B9=EF=BC=88?= =?UTF-8?q?=E5=90=84=E5=88=86=E6=94=AF=E5=8F=AF=E8=BE=BE=E9=9B=86=E4=BA=A4?= =?UTF-8?q?=E9=9B=86=20BFS=20=E6=9C=80=E6=97=A9=E8=8A=82=E7=82=B9=EF=BC=89?= =?UTF-8?q?=E5=90=B8=E9=99=84=E5=9B=9E=E6=9D=A1=E4=BB=B6=E8=8A=82=E7=82=B9?= =?UTF-8?q?=E4=B8=AD=E7=BA=BF=EF=BC=9B=E7=AD=89=E9=95=BF=E5=90=8C=E5=B1=82?= =?UTF-8?q?=E5=88=86=E6=94=AF=E4=B8=BA=E6=81=92=E7=AD=89=E5=8F=98=E6=8D=A2?= =?UTF-8?q?=E4=B8=8D=E5=9B=9E=E5=BD=92=EF=BC=88golden=208/8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/DiagramLayoutService.java | 137 ++++++++++++++++++ .../service/DiagramLayoutServiceTest.java | 54 +++++++ 2 files changed, 191 insertions(+) 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(