From 7c92e953ec0417470b6861b9f5b4d066fa4efb28 Mon Sep 17 00:00:00 2001 From: chenxing Date: Fri, 9 Oct 2026 19:42:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(diagram):=20=E6=B5=81=E7=A8=8B=E5=9B=BE?= =?UTF-8?q?=E7=AB=96=E6=8E=92=E6=94=B9=E9=80=A0=EF=BC=88=E6=A8=AA=E2=86=92?= =?UTF-8?q?=E7=AB=96=EF=BC=89=E2=80=94=E2=80=94=E5=B8=83=E5=B1=80=E5=8D=95?= =?UTF-8?q?=E6=BA=90=E5=9D=90=E6=A0=87=E8=BD=AC=E7=BD=AE+=E5=90=8D?= =?UTF-8?q?=E5=8D=952=E8=A1=8C=E6=8D=A2=E8=A1=8C+=E8=BE=B9=E5=87=A0?= =?UTF-8?q?=E4=BD=95/=E7=AE=AD=E5=A4=B4/=E6=A0=87=E7=AD=BE=E7=BF=BB?= =?UTF-8?q?=E8=BD=AC=EF=BC=8Cgolden=20=E7=94=A8=E4=BE=8B=E6=94=B9=E5=86=99?= =?UTF-8?q?=E5=B9=B6=E6=96=B0=E5=A2=9E=206=20=E5=87=BA=E5=8F=A3=E7=94=A8?= =?UTF-8?q?=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/DiagramImageService.java | 152 +++++++++++++----- .../service/DiagramLayoutService.java | 88 +++++----- .../service/DiagramLayoutServiceTest.java | 90 ++++++++--- 3 files changed, 227 insertions(+), 103 deletions(-) diff --git a/src/main/java/com/yunhai/flowcenter/service/DiagramImageService.java b/src/main/java/com/yunhai/flowcenter/service/DiagramImageService.java index d80a5c6..4c60719 100644 --- a/src/main/java/com/yunhai/flowcenter/service/DiagramImageService.java +++ b/src/main/java/com/yunhai/flowcenter/service/DiagramImageService.java @@ -29,9 +29,10 @@ import java.util.Map; * 而 img 标签带不了 app-sign 请求头,因此《流程中心流程图查看方案.md》§二"不做服务端出图"的 * 边界对外场景作废——本类在服务端把布局 JSON 画成 PNG;前端手绘 SVG 方案仅流程中心自有页面继续用。 *

- * 布局(2026-09-17 重构,与前端 flowLayout 同口径):自左向右拓扑分层(最长路径),并行分支组以 - * SPLIT 中心线为轴垂直居中(SPLIT↔JOIN 配对按各出边可达集求交、取 BFS 序最早的 JOIN),忽略设计器 - * 画布坐标(那是全屏编辑器坐标系,直接缩放会失真);连线为正交折线(右中→中线→左中)。 + * 布局(2026-09-17 重构,与前端 flowLayout 同口径;2026-10-09 竖排):自上向下拓扑分层(最长路径), + * 并行分支组以 SPLIT 竖直中线为轴左右居中(SPLIT↔JOIN 配对按各出边可达集求交、取 BFS 序最早的 JOIN), + * 忽略设计器画布坐标(那是全屏编辑器坐标系,直接缩放会失真);连线为正交折线(底中→中线→顶中)。 + * 名单(subLabel)按盒宽换行 ≤2 行,放不下以"…等 N 人"收尾(与前端 SVG 同口径)。 * 配色与前端示意图一致(蓝审批卡/绿开始/灰结束/橙菱形),节点颜色按运行时状态 * done/active/rejected/pending 上色,图例仅 instance 口径绘制。 * 防御出图:环/孤儿节点/缺失端点均降级为"必出图",不抛渲染异常(外部 img 场景无错误页可跳)。 @@ -140,7 +141,7 @@ public class DiagramImageService { FontMetrics titleFm = g.getFontMetrics(); g.drawString(title, (imgW - titleFm.stringWidth(title)) / 2f, MARGIN + titleFont.getSize() + 2); - // 连线(先画线后画节点,节点底色盖住线头):正交折线 右中→中线→左中 + // 连线(先画线后画节点,节点底色盖住线头):正交折线 底中→竖直段→水平折→顶中 Map> edgeNames = new HashMap<>(); for (FlowModels.EdgeVo e : edges) { edgeNames.computeIfAbsent(e.getFrom(), k -> new HashMap<>()).put(e.getTo(), e.getName()); @@ -152,19 +153,20 @@ public class DiagramImageService { if (from == null || to == null) { continue; } - int x1 = from[0] + from[2]; - int y1 = from[1] + from[3] / 2; - int x2 = to[0]; - int y2 = to[1] + to[3] / 2; - int mid = (x1 + x2) / 2; + int x1 = from[0] + from[2] / 2; + int y1 = from[1] + from[3]; + int x2 = to[0] + to[2] / 2; + int y2 = to[1]; + int mid = (y1 + y2) / 2; g.setColor(C_LINE); - g.drawLine(x1, y1, mid, y1); - g.drawLine(mid, y1, mid, y2); - g.drawLine(mid, y2, x2 - ARROW, y2); - drawArrowHead(g, x2 - ARROW, y2); - // 多出口分支文案:折线竖段中点白底小字(单出口连线不标,避免噪音) + g.drawLine(x1, y1, x1, mid); + g.drawLine(x1, mid, x2, mid); + g.drawLine(x2, mid, x2, y2 - ARROW); + drawArrowHead(g, x2, y2 - ARROW); + // 多出口分支文案:折线水平段中点白底小字(单出口连线不标,避免噪音)。 + // 同源多出口共享下方竖直段,标签挂各自折线中点((x1+x2)/2)才不会互相叠字。 if (edgeNames.get(e.getFrom()).size() > 1 && e.getName() != null && !e.getName().isEmpty()) { - drawMidLabel(g, mid, (y1 + y2) / 2, e.getName(), smallFont); + drawMidLabel(g, (x1 + x2) / 2, mid, e.getName(), smallFont); } } @@ -254,41 +256,43 @@ public class DiagramImageService { return; } if (FlowDefModel.TYPE_NOTIFY.equals(type)) { - // 抄送:圆角卡(青框白底,label + 单行 subLabel)——瞬时节点不占 active 红, - // 状态着色与审批一致(done 绿框表示本办件已抄送过) + // 抄送:圆角卡(青框白底)——瞬时节点不占 active 红, + // 状态着色与审批一致(done 绿框表示本办件已抄送过);标题 + ≤2 行名单(仅标题取青色) g.setColor(C_NOTIFY_FILL); g.fillRoundRect(p[0], p[1], p[2], p[3], 12, 12); g.setColor(borderOf(n)); g.setStroke(new BasicStroke("active".equals(n.getState()) ? 2.4f : 1.6f)); g.drawRoundRect(p[0], p[1], p[2], p[3], 12, 12); - g.setFont(labelF); - g.setColor(C_NOTIFY_BORDER); - String label = truncate(g, n.getLabel(), p[2] - 20); - g.drawString(label, p[0] + (p[2] - g.getFontMetrics().stringWidth(label)) / 2f, p[1] + 26); - if (n.getSubLabel() != null && !n.getSubLabel().isEmpty()) { - g.setFont(subF); - g.setColor(C_SUB); - String sub = truncate(g, n.getSubLabel(), p[2] - 20); - g.drawString(sub, p[0] + (p[2] - g.getFontMetrics().stringWidth(sub)) / 2f, p[1] + 46); - } + drawNameCardText(g, n, p, labelF, subF, C_NOTIFY_BORDER); return; } - // 审批:圆角卡(默认蓝框;done 绿/active 红/pending 灰/rejected 红底),label + 单行 subLabel + // 审批:圆角卡(默认蓝框;done 绿/active 红/pending 灰/rejected 红底),标题 + ≤2 行名单 g.setColor("rejected".equals(n.getState()) ? C_REJECT_FILL : Color.WHITE); g.fillRoundRect(p[0], p[1], p[2], p[3], 12, 12); boolean active = "active".equals(n.getState()); g.setColor(borderOf(n)); g.setStroke(new BasicStroke(active ? 2.4f : 1.6f)); g.drawRoundRect(p[0], p[1], p[2], p[3], 12, 12); + drawNameCardText(g, n, p, labelF, subF, C_TEXT); + } + + /** + * 名单卡文本(76 高盒竖排口径):标题 1 行居中 + 名单 ≤2 行居中(换行规则见 {@link #wrapNameList}); + * 名单 2 行时标题基线 25、名单基线 44/61,单行名单时 27/50(视觉居中微调,与前端 SVG 同口径)。 + */ + private void drawNameCardText(Graphics2D g, FlowModels.NodeVo n, int[] p, Font labelF, Font subF, Color labelColor) { + String[] lines = wrapNameList(g.getFontMetrics(subF), n.getSubLabel(), p[2] - 20); g.setFont(labelF); - g.setColor(C_TEXT); + g.setColor(labelColor); String label = truncate(g, n.getLabel(), p[2] - 20); - g.drawString(label, p[0] + (p[2] - g.getFontMetrics().stringWidth(label)) / 2f, p[1] + 26); - if (n.getSubLabel() != null && !n.getSubLabel().isEmpty()) { - g.setFont(subF); - g.setColor(C_SUB); - String sub = truncate(g, n.getSubLabel(), p[2] - 20); - g.drawString(sub, p[0] + (p[2] - g.getFontMetrics().stringWidth(sub)) / 2f, p[1] + 46); + g.drawString(label, p[0] + (p[2] - g.getFontMetrics().stringWidth(label)) / 2f, + p[1] + (lines.length >= 2 ? 25 : 27)); + g.setFont(subF); + g.setColor(C_SUB); + int[] subBases = lines.length >= 2 ? new int[]{44, 61} : new int[]{50}; + for (int i = 0; i < lines.length; i++) { + g.drawString(lines[i], p[0] + (p[2] - g.getFontMetrics().stringWidth(lines[i])) / 2f, + p[1] + subBases[i]); } } @@ -302,12 +306,12 @@ public class DiagramImageService { g.drawString(s, midX - sw / 2f, midY + fm.getAscent() / 2f - 1); } - /** 箭头指向 +x(分层自左向右,目标恒在右侧) */ + /** 箭头指向 +y(分层自上向下,目标恒在下方) */ private void drawArrowHead(Graphics2D g, int x, int y) { Path2D p = new Path2D.Float(); - p.moveTo(x + ARROW, y); - p.lineTo(x, y - ARROW / 2.0 + 1); - p.lineTo(x, y + ARROW / 2.0 - 1); + p.moveTo(x, y + ARROW); + p.lineTo(x - ARROW / 2.0 + 1, y); + p.lineTo(x + ARROW / 2.0 - 1, y); p.closePath(); g.setColor(C_LINE); g.fill(p); @@ -420,10 +424,13 @@ public class DiagramImageService { } private static String truncate(Graphics2D g, String s, int width) { + return truncate(g.getFontMetrics(), s, width); + } + + private static String truncate(FontMetrics fm, String s, int width) { if (s == null || s.isEmpty()) { return ""; } - FontMetrics fm = g.getFontMetrics(); if (fm.stringWidth(s) <= width) { return s; } @@ -432,4 +439,69 @@ public class DiagramImageService { } return s + "…"; } + + /** + * 名单换行(≤2 行,竖排名单卡口径,与前端 FlowDiagram.vue 同规则):按「、」整名装箱换行 + * (「 · 」后缀语随最后一名同行,整名超宽时按字截断防御);全量放得下原样分行;放不下时 + * 两行装满后以「…等 N 人」收尾(N=未展示人数,第 2 行预留尾缀宽度)。 + */ + static String[] wrapNameList(FontMetrics fm, String s, int width) { + if (s == null || s.isEmpty()) { + return new String[0]; + } + String[] people = s.split("、", -1); + // 全量贪心装箱(行数不限):先判断是否真的放得下 + List packed = new ArrayList<>(); + StringBuilder cur = new StringBuilder(); + for (String raw : people) { + String p = fm.stringWidth(raw) > width ? truncate(fm, raw, width) : raw; + String cand = cur.length() == 0 ? p : cur + "、" + p; + if (fm.stringWidth(cand) <= width) { + cur.setLength(0); + cur.append(cand); + } else { + packed.add(cur.toString()); + cur = new StringBuilder(p); + } + } + packed.add(cur.toString()); + if (packed.size() <= 2) { + return packed.toArray(new String[0]); + } + // 放不下:两行装满 + 「…等 N 人」尾缀(第 2 行装箱时预留尾缀宽度) + int n = people.length; + StringBuilder l1 = new StringBuilder(); + int shown = 0; + while (shown < n) { + String p = fm.stringWidth(people[shown]) > width ? truncate(fm, people[shown], width) : people[shown]; + String cand = l1.length() == 0 ? p : l1 + "、" + p; + if (fm.stringWidth(cand) <= width) { + l1.setLength(0); + l1.append(cand); + shown++; + } else { + break; + } + } + StringBuilder l2 = new StringBuilder(); + while (shown < n) { + int restAfter = n - shown - 1; + String tail = restAfter > 0 ? "…等 " + restAfter + " 人" : ""; + String p = fm.stringWidth(people[shown]) > width ? truncate(fm, people[shown], width) : people[shown]; + String cand = l2.length() == 0 ? p : l2 + "、" + p; + if (fm.stringWidth(cand) + (tail.isEmpty() ? 0 : fm.stringWidth(tail)) <= width) { + l2.setLength(0); + l2.append(cand); + shown++; + } else { + break; + } + } + int rest = n - shown; + if (l2.length() == 0) { + // 第 2 行连一名+尾缀都放不下:整行只展示尾缀(人数语义完整优先于名单) + return new String[]{l1.toString(), "…等 " + rest + " 人"}; + } + return new String[]{l1.toString(), l2.append(rest > 0 ? "…等 " + rest + " 人" : "").toString()}; + } } diff --git a/src/main/java/com/yunhai/flowcenter/service/DiagramLayoutService.java b/src/main/java/com/yunhai/flowcenter/service/DiagramLayoutService.java index 55001ce..8b395a6 100644 --- a/src/main/java/com/yunhai/flowcenter/service/DiagramLayoutService.java +++ b/src/main/java/com/yunhai/flowcenter/service/DiagramLayoutService.java @@ -11,20 +11,22 @@ import java.util.List; import java.util.Map; /** - * 流程图布局单源(2026-10-03 三视图统一):自 DiagramImageService 原样搬移(算法与常量不变, - * PNG 出图逐像素不变)。拓扑最长路径分层(层=列,自左向右),并行分支组以 SPLIT 中心线为轴 - * 垂直居中(SPLIT↔JOIN 配对按各出边可达集求交、取 BFS 序最早的 JOIN);输出内容区坐标 - * (minX/minY 已归零),PNG 渲染与 DiagramVo.positions 共同消费——改布局只改这里。 + * 流程图布局单源(2026-10-03 三视图统一;2026-10-09 竖排改造):拓扑最长路径分层 + * (层=行,自上向下),层内按定义顺序横排、整层块对竖直中线水平居中;并行分支组以 + * SPLIT 竖直中线为轴左右对称展开(SPLIT↔JOIN 配对按各出边可达集求交、取 BFS 序最早的 + * JOIN);输出内容区坐标(minX/minY 已归零),PNG 渲染与 DiagramVo.positions 共同消费—— + * 改布局只改这里。 */ @Service public class DiagramLayoutService { - /** 逻辑坐标常量(盒型在槽内居中;列间距留给正交折线走线) */ - static final int SLOT_W = 210; // 列槽宽(盒型在槽内水平居中) - static final int GAP_X = 84; // 列间距(留给正交折线走线) - static final int ROW_H = 92; // 行槽高(盒型在槽内垂直居中) + /** 逻辑坐标常量(盒型在槽内居中;层间距留给竖直走线与多出口文案,分支列间无需走线空间) */ + static final int SLOT_H = 100; // 层槽高(盒型在槽内垂直居中) + static final int GAP_Y = 56; // 层间距(竖直段 + 多出口文案空间) + static final int SLOT_W = 280; // 分支槽宽(盒型在槽内水平居中) + static final int GAP_X = 44; // 分支列间距 - /** 各类型盒型(w,h):开始/结束胶囊、审批圆角卡、并行/条件菱形(取外接矩形) */ + /** 各类型盒型(w,h):开始/结束胶囊、审批圆角卡(名单 2 行)、并行/条件菱形(取外接矩形) */ static int[] boxOf(String type) { switch (type == null ? "" : type) { case FlowDefModel.TYPE_START: @@ -35,7 +37,7 @@ public class DiagramLayoutService { case FlowDefModel.TYPE_CONDITION: return new int[]{132, 78}; default: - return new int[]{200, 64}; + return new int[]{264, 76}; } } @@ -56,7 +58,7 @@ public class DiagramLayoutService { inDeg.merge(e.getTo(), 1, Integer::sum); } } - // 拓扑最长路径分层(层=列,自左向右);环兜底:无入度的从层 0 起算,成环残留节点强制层 0(仍必出图) + // 拓扑最长路径分层(层=行,自上向下);环兜底:无入度的从层 0 起算,成环残留节点强制层 0(仍必出图) Map level = new HashMap<>(); List queue = new ArrayList<>(); for (Map.Entry en : inDeg.entrySet()) { @@ -80,35 +82,33 @@ public class DiagramLayoutService { level.putIfAbsent(n.getId(), 0); } - // 初始网格:列 x 由层号决定,列内按定义顺序纵排、整列块以内容区中线居中 + // 初始网格:行 y 由层号决定,层内按定义顺序横排、整层块以内容区竖直中线居中 Map> layers = new LinkedHashMap<>(); - int maxLevel = 0; - int maxRows = 1; + int maxCols = 1; for (FlowModels.NodeVo n : nodes) { int l = level.get(n.getId()); - maxLevel = Math.max(maxLevel, l); layers.computeIfAbsent(l, k -> new ArrayList<>()).add(n); } for (List layer : layers.values()) { - maxRows = Math.max(maxRows, layer.size()); + maxCols = Math.max(maxCols, layer.size()); } - int axisH = maxRows * ROW_H; + int axisW = (maxCols - 1) * (SLOT_W + GAP_X) + SLOT_W; Map box = new HashMap<>(); for (Map.Entry> en : layers.entrySet()) { List layer = en.getValue(); - int blockH = layer.size() * ROW_H; - int top0 = (axisH - blockH) / 2; + int blockW = (layer.size() - 1) * (SLOT_W + GAP_X) + SLOT_W; + int left0 = (axisW - blockW) / 2; for (int i = 0; i < layer.size(); i++) { FlowModels.NodeVo n = layer.get(i); int[] wh = boxOf(n.getType()); box.put(n.getId(), new int[]{ - en.getKey() * (SLOT_W + GAP_X) + (SLOT_W - wh[0]) / 2, - top0 + i * ROW_H + (ROW_H - wh[1]) / 2, + left0 + i * (SLOT_W + GAP_X) + (SLOT_W - wh[0]) / 2, + en.getKey() * (SLOT_H + GAP_Y) + (SLOT_H - wh[1]) / 2, wh[0], wh[1]}); } } - // 并行分支组以 SPLIT 中心线为轴垂直居中(内层组先处理,最后整体抬回顶部 0) + // 并行分支组以 SPLIT 竖直中线为轴左右居中(内层组先处理,最后整体推回左边界 0) centerParallelGroups(nodes, out, box); // 归零(2026-10-03 三视图统一新增):全体平移使 minX=minY=0,positions 消费端无需再算包围盒 @@ -130,10 +130,10 @@ public class DiagramLayoutService { } /** - * 并行分支组垂直居中:分支不再从 SPLIT 行向下堆叠,而是以 SPLIT 中心线为轴上下对称展开。 + * 并行分支组水平居中:分支不再从 SPLIT 行向右堆叠,而是以 SPLIT 竖直中线为轴左右对称展开。 * SPLIT↔JOIN 配对沿用编辑器口径(各出边可达集求交,取 BFS 序最早的 PARALLEL_JOIN); - * 组员 = SPLIT 到 JOIN 之间可达的全部节点;JOIN 中线吸附回 SPLIT 中线。 - * 内层组先于外层组处理(嵌套并行整体平移不破坏内层居中);结束统一把最顶行抬回 0。 + * 组员 = SPLIT 到 JOIN 之间可达的全部节点;JOIN 水平中点吸附回 SPLIT 中线。 + * 内层组先于外层组处理(嵌套并行整体平移不破坏内层居中);结束统一把最左列推回 0。 */ private void centerParallelGroups(List nodes, Map> out, Map box) { @@ -141,12 +141,12 @@ public class DiagramLayoutService { final String split; final String join; final List members; - final int maxX; - Group(String split, String join, List members, int maxX) { + final int maxY; + Group(String split, String join, List members, int maxY) { this.split = split; this.join = join; this.members = members; - this.maxX = maxX; + this.maxY = maxY; } } List groups = new ArrayList<>(); @@ -224,37 +224,37 @@ public class DiagramLayoutService { } int maxLevel = 0; for (String m : members) { - maxLevel = Math.max(maxLevel, box.get(m)[0]); + maxLevel = Math.max(maxLevel, box.get(m)[1]); } groups.add(new Group(sid, join, members, maxLevel)); } - // maxX 降序 = 内层组先处理 - groups.sort((a, b) -> Integer.compare(b.maxX, a.maxX)); + // maxY 降序 = 内层组先处理 + groups.sort((a, b) -> Integer.compare(b.maxY, a.maxY)); for (Group gp : groups) { int[] s = box.get(gp.split); - int axisY = s[1] + s[3] / 2; - int top = Integer.MAX_VALUE; - int bottom = Integer.MIN_VALUE; + int axisX = s[0] + s[2] / 2; + int left = Integer.MAX_VALUE; + int right = Integer.MIN_VALUE; for (String m : gp.members) { int[] b = box.get(m); - top = Math.min(top, b[1]); - bottom = Math.max(bottom, b[1] + b[3]); + left = Math.min(left, b[0]); + right = Math.max(right, b[0] + b[2]); } - int delta = axisY - (top + bottom) / 2; + int delta = axisX - (left + right) / 2; for (String m : gp.members) { - box.get(m)[1] += delta; + box.get(m)[0] += delta; } int[] j = box.get(gp.join); - j[1] += axisY - (j[1] + j[3] / 2); + j[0] += axisX - (j[0] + j[2] / 2); } - // 上移出界(y<0)的组把整图平移回 0(同 delta 平移不破坏各组居中) - int minY = Integer.MAX_VALUE; + // 左移出界(x<0)的组把整图平移回 0(同 delta 平移不破坏各组居中) + int minX = Integer.MAX_VALUE; for (int[] b : box.values()) { - minY = Math.min(minY, b[1]); + minX = Math.min(minX, b[0]); } - if (minY < 0) { + if (minX < 0) { for (int[] b : box.values()) { - b[1] -= minY; + b[0] -= minX; } } } diff --git a/src/test/java/com/yunhai/flowcenter/service/DiagramLayoutServiceTest.java b/src/test/java/com/yunhai/flowcenter/service/DiagramLayoutServiceTest.java index cb889b5..3a45304 100644 --- a/src/test/java/com/yunhai/flowcenter/service/DiagramLayoutServiceTest.java +++ b/src/test/java/com/yunhai/flowcenter/service/DiagramLayoutServiceTest.java @@ -15,8 +15,8 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; /** - * 布局单源 golden 用例(2026-10-03 三视图统一):PNG 与 DiagramVo.positions 共同消费这份几何。 - * 数值断言 ±1 容差(int 除法);关系断言(列距/居中/归零)与常量解耦,防调参误报。 + * 布局单源 golden 用例(2026-10-03 三视图统一;2026-10-09 竖排改写):PNG 与 DiagramVo.positions + * 共同消费这份几何。数值断言 ±1 容差(int 除法);关系断言(行距/居中/归零)与常量解耦,防调参误报。 */ class DiagramLayoutServiceTest { @@ -41,12 +41,16 @@ class DiagramLayoutServiceTest { return service.layout(nodes, new ArrayList<>(Arrays.asList(edges))); } + private int cx(int[] b) { + return b[0] + b[2] / 2; + } + private int cy(int[] b) { return b[1] + b[3] / 2; } @Test - void linear_xPitchByColumn_andZeroMin() { + void linear_rowPitchByLevel_centeredAndZeroMin() { List nodes = new ArrayList<>(Arrays.asList( node("start", FlowDefModel.TYPE_START), node("a", FlowDefModel.TYPE_APPROVAL), @@ -56,8 +60,16 @@ class DiagramLayoutServiceTest { for (FlowModels.NodeVo n : nodes) { assertNotNull(box.get(n.getId()), "缺坐标:" + n.getId()); } - // 同宽节点(审批→审批)x 差 = 列距 SLOT_W+GAP_X(±1 容差) - assertEquals(DiagramLayoutService.SLOT_W + DiagramLayoutService.GAP_X, box.get("b")[0] - box.get("a")[0], 1); + // 纯串行:相邻层 y 差 = 行距 SLOT_H+GAP_Y(±1 容差;顶差仅同盒高成立,跨盒型断中心差), + // 主链各层单列块水平居中 → 中心 x 相等 + assertEquals(DiagramLayoutService.SLOT_H + DiagramLayoutService.GAP_Y, box.get("b")[1] - box.get("a")[1], 1); + assertEquals(DiagramLayoutService.SLOT_H + DiagramLayoutService.GAP_Y, + cy(box.get("a")) - cy(box.get("start")), 1); + assertEquals(DiagramLayoutService.SLOT_H + DiagramLayoutService.GAP_Y, + cy(box.get("end")) - cy(box.get("b")), 1); + assertEquals(cx(box.get("start")), cx(box.get("a")), 1); + assertEquals(cx(box.get("a")), cx(box.get("b")), 1); + assertEquals(cx(box.get("b")), cx(box.get("end")), 1); // 归零:全部坐标非负且最小 x/y 为 0 int minX = Integer.MAX_VALUE; int minY = Integer.MAX_VALUE; @@ -71,7 +83,7 @@ class DiagramLayoutServiceTest { } @Test - void conditionBranches_sameColumnBlockVerticallyCentered() { + void conditionBranches_sameRowBlockHorizontallyCentered() { List nodes = new ArrayList<>(Arrays.asList( node("start", FlowDefModel.TYPE_START), node("c", FlowDefModel.TYPE_CONDITION), @@ -80,10 +92,13 @@ class DiagramLayoutServiceTest { node("end", FlowDefModel.TYPE_END))); Map box = layout(nodes, edge("start", "c"), edge("c", "a"), edge("c", "b"), edge("a", "end"), edge("b", "end")); - // 两分支同列不同行,列块垂直居中:两盒中心均值 ≈ 内容区中线(单行列的中心同值) - assertEquals(box.get("a")[0], box.get("b")[0]); - assertTrue(box.get("a")[1] != box.get("b")[1]); - assertEquals(cy(box.get("c")), (cy(box.get("a")) + cy(box.get("b"))) / 2, 1); + // 同层分支:顶 y 对齐、各占一列等间距(列距 SLOT_W+GAP_X) + assertEquals(box.get("a")[1], box.get("b")[1]); + assertTrue(box.get("a")[0] != box.get("b")[0]); + assertEquals(DiagramLayoutService.SLOT_W + DiagramLayoutService.GAP_X, + Math.abs(box.get("b")[0] - box.get("a")[0]), 1); + // 整层块水平居中:两分支中心均值 ≈ 单节点层的中心(条件菱形与内容区中线同轴) + assertEquals(cx(box.get("c")), (cx(box.get("a")) + cx(box.get("b"))) / 2, 1); } @Test @@ -98,13 +113,50 @@ class DiagramLayoutServiceTest { Map box = layout(nodes, edge("start", "s"), edge("s", "a"), edge("s", "b"), edge("a", "j"), edge("b", "j"), edge("j", "end")); - // split 与 join 同一水平中线,且即两分支的对称轴(centerParallelGroups 生效) - assertEquals(cy(box.get("s")), cy(box.get("j")), 1); - assertEquals(cy(box.get("s")), (cy(box.get("a")) + cy(box.get("b"))) / 2, 1); + // 单并行组:分支头同行水平对齐,以 SPLIT 竖直中线左右对称展开;JOIN 水平中点吸附回 SPLIT 中线 + assertEquals(box.get("a")[1], box.get("b")[1]); + assertEquals(cx(box.get("s")), (cx(box.get("a")) + cx(box.get("b"))) / 2, 1); + assertEquals(cx(box.get("s")), cx(box.get("j")), 1); } @Test - void nestedParallel_groupsCentered_noNegativeY() { + void wideBranches_sixOutlets_spreadHorizontallyWithoutOverlap() { + List nodes = new ArrayList<>(Arrays.asList( + node("start", FlowDefModel.TYPE_START), + node("c", FlowDefModel.TYPE_CONDITION), + node("a1", FlowDefModel.TYPE_APPROVAL), + node("a2", FlowDefModel.TYPE_APPROVAL), + node("a3", FlowDefModel.TYPE_APPROVAL), + node("a4", FlowDefModel.TYPE_APPROVAL), + node("a5", FlowDefModel.TYPE_APPROVAL), + node("a6", FlowDefModel.TYPE_APPROVAL), + node("end", FlowDefModel.TYPE_END))); + Map box = layout(nodes, + edge("start", "c"), + edge("c", "a1"), edge("c", "a2"), edge("c", "a3"), + edge("c", "a4"), edge("c", "a5"), edge("c", "a6"), + edge("a1", "end"), edge("a2", "end"), edge("a3", "end"), + edge("a4", "end"), edge("a5", "end"), edge("a6", "end")); + // 6 出口同层:顶 y 对齐、水平等间距展开、两两无重叠、整层块仍居中(首末中心均值=菱形中心) + List branches = new ArrayList<>(); + for (int i = 1; i <= 6; i++) { + branches.add(box.get("a" + i)); + } + branches.sort((x, y) -> Integer.compare(x[0], y[0])); + for (int[] b : branches) { + assertEquals(branches.get(0)[1], b[1]); + } + for (int i = 1; i < branches.size(); i++) { + assertEquals(DiagramLayoutService.SLOT_W + DiagramLayoutService.GAP_X, + branches.get(i)[0] - branches.get(i - 1)[0], 1); + assertTrue(branches.get(i)[0] >= branches.get(i - 1)[0] + branches.get(i - 1)[2], + "分支重叠:" + (branches.get(i)[0] - branches.get(i - 1)[0])); + } + assertEquals(cx(box.get("c")), (cx(branches.get(0)) + cx(branches.get(5))) / 2, 1); + } + + @Test + void nestedParallel_groupsCentered_noNegativeX() { List nodes = new ArrayList<>(Arrays.asList( node("start", FlowDefModel.TYPE_START), node("os", FlowDefModel.TYPE_PARALLEL_SPLIT), @@ -122,12 +174,12 @@ class DiagramLayoutServiceTest { edge("p", "ij"), edge("q", "ij"), edge("ij", "oj"), edge("c", "oj"), edge("oj", "end")); - // 内层组:is 与 {p,q} 居中;外层组:os/oj 同中线即分支对称轴 - assertEquals(cy(box.get("is")), (cy(box.get("p")) + cy(box.get("q"))) / 2, 1); - assertEquals(cy(box.get("os")), cy(box.get("oj")), 1); - // 全图无 y<0(centerParallelGroups 末段抬回) + // 内层组:is 与 {p,q} 居中;外层组:os/oj 同中线即分支对称轴(内层先处理不被外层破坏) + assertEquals(cx(box.get("is")), (cx(box.get("p")) + cx(box.get("q"))) / 2, 1); + assertEquals(cx(box.get("os")), cx(box.get("oj")), 1); + // 全图无 x<0(centerParallelGroups 末段推回) for (int[] b : box.values()) { - assertTrue(b[1] >= 0, "出现负 y:" + b[1]); + assertTrue(b[0] >= 0, "出现负 x:" + b[0]); } }