From cacdd4ee14a823bbba208dc81736cac06bf93026 Mon Sep 17 00:00:00 2001 From: chenxing Date: Sat, 3 Oct 2026 13:46:57 +0800 Subject: [PATCH] =?UTF-8?q?refactor(diagram):=20=E5=B8=83=E5=B1=80?= =?UTF-8?q?=E8=AE=A1=E7=AE=97=E6=8A=BD=20DiagramLayoutService=EF=BC=8CPNG?= =?UTF-8?q?=20=E5=87=BA=E5=9B=BE=E4=B8=8D=E5=8F=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/DiagramImageService.java | 227 +-------------- .../service/DiagramLayoutService.java | 261 ++++++++++++++++++ .../service/DiagramImageServiceTest.java | 2 +- 3 files changed, 271 insertions(+), 219 deletions(-) create mode 100644 src/main/java/com/yunhai/flowcenter/service/DiagramLayoutService.java diff --git a/src/main/java/com/yunhai/flowcenter/service/DiagramImageService.java b/src/main/java/com/yunhai/flowcenter/service/DiagramImageService.java index f3089ec..d80a5c6 100644 --- a/src/main/java/com/yunhai/flowcenter/service/DiagramImageService.java +++ b/src/main/java/com/yunhai/flowcenter/service/DiagramImageService.java @@ -19,7 +19,6 @@ import java.io.ByteArrayOutputStream; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -36,36 +35,26 @@ import java.util.Map; * 配色与前端示意图一致(蓝审批卡/绿开始/灰结束/橙菱形),节点颜色按运行时状态 * done/active/rejected/pending 上色,图例仅 instance 口径绘制。 * 防御出图:环/孤儿节点/缺失端点均降级为"必出图",不抛渲染异常(外部 img 场景无错误页可跳)。 + *

+ * 布局已抽至 {@link DiagramLayoutService}(2026-10-03 三视图统一),本类只做 PNG 绘制。 */ @Slf4j @Service public class DiagramImageService { + private final DiagramLayoutService diagramLayoutService; + + public DiagramImageService(DiagramLayoutService diagramLayoutService) { + this.diagramLayoutService = diagramLayoutService; + } + /** 逻辑坐标常量(输出按 SCALE 倍导出,保证 缩放清晰) */ private static final int SCALE = 2; private static final int MARGIN = 36; private static final int TITLE_H = 56; - private static final int SLOT_W = 210; // 列槽宽(盒型在槽内水平居中) - private static final int GAP_X = 84; // 列间距(留给正交折线走线) - private static final int ROW_H = 92; // 行槽高(盒型在槽内垂直居中) private static final int LEGEND_H = 48; private static final int ARROW = 9; - /** 各类型盒型(w,h):开始/结束胶囊、审批圆角卡、并行/条件菱形(取外接矩形) */ - private static int[] boxOf(String type) { - switch (type == null ? "" : type) { - case FlowDefModel.TYPE_START: - case FlowDefModel.TYPE_END: - return new int[]{116, 44}; - case FlowDefModel.TYPE_PARALLEL_SPLIT: - case FlowDefModel.TYPE_PARALLEL_JOIN: - case FlowDefModel.TYPE_CONDITION: - return new int[]{132, 78}; - default: - return new int[]{200, 64}; - } - } - // —— 配色与前端 FlowDiagram.vue 示意图一致(深色线/高对比文字) —— private static final Color C_TITLE = new Color(0x17, 0x23, 0x3D); private static final Color C_LINE = new Color(0x51, 0x5A, 0x6E); @@ -103,75 +92,7 @@ public class DiagramImageService { private BufferedImage draw(FlowModels.DiagramVo vo) { List nodes = vo.getNodes() == null ? new ArrayList<>() : vo.getNodes(); List edges = vo.getEdges() == null ? new ArrayList<>() : vo.getEdges(); - Map byId = new HashMap<>(); - for (FlowModels.NodeVo n : nodes) { - byId.put(n.getId(), n); - } - Map> out = new HashMap<>(); - Map inDeg = new LinkedHashMap<>(); - for (FlowModels.NodeVo n : nodes) { - inDeg.put(n.getId(), 0); - } - for (FlowModels.EdgeVo e : edges) { - if (byId.containsKey(e.getFrom()) && byId.containsKey(e.getTo())) { - out.computeIfAbsent(e.getFrom(), k -> new ArrayList<>()).add(e.getTo()); - inDeg.merge(e.getTo(), 1, Integer::sum); - } - } - // 拓扑最长路径分层(层=列,自左向右);环兜底:无入度的从层 0 起算,成环残留节点强制层 0(仍必出图) - Map level = new HashMap<>(); - List queue = new ArrayList<>(); - for (Map.Entry en : inDeg.entrySet()) { - if (en.getValue() == 0) { - level.put(en.getKey(), 0); - queue.add(en.getKey()); - } - } - while (!queue.isEmpty()) { - String id = queue.remove(queue.size() - 1); - int parent = level.get(id); - for (String to : out.getOrDefault(id, new ArrayList<>())) { - level.merge(to, parent + 1, Math::max); - int d = inDeg.merge(to, -1, Integer::sum); - if (d == 0) { - queue.add(to); - } - } - } - for (FlowModels.NodeVo n : nodes) { - level.putIfAbsent(n.getId(), 0); - } - - // 初始网格:列 x 由层号决定,列内按定义顺序纵排、整列块以内容区中线居中 - Map> layers = new LinkedHashMap<>(); - int maxLevel = 0; - int maxRows = 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()); - } - int axisH = maxRows * ROW_H; - 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; - 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, - wh[0], wh[1]}); - } - } - - // 并行分支组以 SPLIT 中心线为轴垂直居中(内层组先处理,最后整体抬回顶部 0) - centerParallelGroups(nodes, out, box); + Map box = diagramLayoutService.layout(nodes, edges); // 依据实际包围盒定画布尺寸并整体平移进留白 int minX = Integer.MAX_VALUE; @@ -273,136 +194,6 @@ public class DiagramImageService { return canvas; } - /** - * 并行分支组垂直居中:分支不再从 SPLIT 行向下堆叠,而是以 SPLIT 中心线为轴上下对称展开。 - * SPLIT↔JOIN 配对沿用编辑器口径(各出边可达集求交,取 BFS 序最早的 PARALLEL_JOIN); - * 组员 = SPLIT 到 JOIN 之间可达的全部节点;JOIN 中线吸附回 SPLIT 中线。 - * 内层组先于外层组处理(嵌套并行整体平移不破坏内层居中);结束统一把最顶行抬回 0。 - */ - private void centerParallelGroups(List nodes, Map> out, - Map box) { - class Group { - final String split; - final String join; - final List members; - final int maxX; - Group(String split, String join, List members, int maxX) { - this.split = split; - this.join = join; - this.members = members; - this.maxX = maxX; - } - } - List groups = new ArrayList<>(); - for (FlowModels.NodeVo split : nodes) { - if (!FlowDefModel.TYPE_PARALLEL_SPLIT.equals(split.getType()) || !box.containsKey(split.getId())) { - continue; - } - String sid = split.getId(); - // BFS 序 + 各分支可达集,用于定位配对 JOIN - Map order = new HashMap<>(); - order.put(sid, 0); - List bfs = new ArrayList<>(); - bfs.add(sid); - 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); - } - } - } - List heads = out.getOrDefault(sid, new ArrayList<>()); - 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 (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); - } - String join = null; - int joinOrder = Integer.MAX_VALUE; - for (FlowModels.NodeVo n : nodes) { - if (FlowDefModel.TYPE_PARALLEL_JOIN.equals(n.getType()) && box.containsKey(n.getId()) - && common.contains(n.getId())) { - int o = order.getOrDefault(n.getId(), Integer.MAX_VALUE); - if (o < joinOrder) { - joinOrder = o; - join = n.getId(); - } - } - } - if (join == null) { - continue; - } - // 组员:SPLIT 起步、不穿过 JOIN 的可达集(含嵌套 SPLIT/JOIN) - List members = new ArrayList<>(); - java.util.Set seen = new java.util.HashSet<>(); - seen.add(join); - List q = new ArrayList<>(); - q.add(sid); - while (!q.isEmpty()) { - String cur = q.remove(0); - for (String to : out.getOrDefault(cur, new ArrayList<>())) { - if (seen.contains(to) || !box.containsKey(to)) { - continue; - } - seen.add(to); - members.add(to); - q.add(to); - } - } - int maxLevel = 0; - for (String m : members) { - maxLevel = Math.max(maxLevel, box.get(m)[0]); - } - groups.add(new Group(sid, join, members, maxLevel)); - } - // maxX 降序 = 内层组先处理 - groups.sort((a, b) -> Integer.compare(b.maxX, a.maxX)); - 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; - for (String m : gp.members) { - int[] b = box.get(m); - top = Math.min(top, b[1]); - bottom = Math.max(bottom, b[1] + b[3]); - } - int delta = axisY - (top + bottom) / 2; - for (String m : gp.members) { - box.get(m)[1] += delta; - } - int[] j = box.get(gp.join); - j[1] += axisY - (j[1] + j[3] / 2); - } - // 上移出界(y<0)的组把整图平移回 0(同 delta 平移不破坏各组居中) - int minY = Integer.MAX_VALUE; - for (int[] b : box.values()) { - minY = Math.min(minY, b[1]); - } - if (minY < 0) { - for (int[] b : box.values()) { - b[1] -= minY; - } - } - } - private void drawNode(Graphics2D g, FlowModels.NodeVo n, int[] p, Font labelF, Font subF) { String type = n.getType() == null ? "" : n.getType(); if (FlowDefModel.TYPE_START.equals(type) || FlowDefModel.TYPE_END.equals(type)) { diff --git a/src/main/java/com/yunhai/flowcenter/service/DiagramLayoutService.java b/src/main/java/com/yunhai/flowcenter/service/DiagramLayoutService.java new file mode 100644 index 0000000..55001ce --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/service/DiagramLayoutService.java @@ -0,0 +1,261 @@ +package com.yunhai.flowcenter.service; + +import com.yunhai.flowcenter.engine.FlowDefModel; +import com.yunhai.flowcenter.model.FlowModels; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 流程图布局单源(2026-10-03 三视图统一):自 DiagramImageService 原样搬移(算法与常量不变, + * PNG 出图逐像素不变)。拓扑最长路径分层(层=列,自左向右),并行分支组以 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; // 行槽高(盒型在槽内垂直居中) + + /** 各类型盒型(w,h):开始/结束胶囊、审批圆角卡、并行/条件菱形(取外接矩形) */ + static int[] boxOf(String type) { + switch (type == null ? "" : type) { + case FlowDefModel.TYPE_START: + case FlowDefModel.TYPE_END: + return new int[]{116, 44}; + case FlowDefModel.TYPE_PARALLEL_SPLIT: + case FlowDefModel.TYPE_PARALLEL_JOIN: + case FlowDefModel.TYPE_CONDITION: + return new int[]{132, 78}; + default: + return new int[]{200, 64}; + } + } + + /** nodes+edges → nodeId→{x,y,w,h}(内容区坐标,minX/minY 已归零)。PNG 与 DiagramVo.positions 共同消费;改布局只改这里。 */ + public Map layout(List nodes, List edges) { + Map byId = new HashMap<>(); + for (FlowModels.NodeVo n : nodes) { + byId.put(n.getId(), n); + } + Map> out = new HashMap<>(); + Map inDeg = new LinkedHashMap<>(); + for (FlowModels.NodeVo n : nodes) { + inDeg.put(n.getId(), 0); + } + for (FlowModels.EdgeVo e : edges) { + if (byId.containsKey(e.getFrom()) && byId.containsKey(e.getTo())) { + out.computeIfAbsent(e.getFrom(), k -> new ArrayList<>()).add(e.getTo()); + inDeg.merge(e.getTo(), 1, Integer::sum); + } + } + // 拓扑最长路径分层(层=列,自左向右);环兜底:无入度的从层 0 起算,成环残留节点强制层 0(仍必出图) + Map level = new HashMap<>(); + List queue = new ArrayList<>(); + for (Map.Entry en : inDeg.entrySet()) { + if (en.getValue() == 0) { + level.put(en.getKey(), 0); + queue.add(en.getKey()); + } + } + while (!queue.isEmpty()) { + String id = queue.remove(queue.size() - 1); + int parent = level.get(id); + for (String to : out.getOrDefault(id, new ArrayList<>())) { + level.merge(to, parent + 1, Math::max); + int d = inDeg.merge(to, -1, Integer::sum); + if (d == 0) { + queue.add(to); + } + } + } + for (FlowModels.NodeVo n : nodes) { + level.putIfAbsent(n.getId(), 0); + } + + // 初始网格:列 x 由层号决定,列内按定义顺序纵排、整列块以内容区中线居中 + Map> layers = new LinkedHashMap<>(); + int maxLevel = 0; + int maxRows = 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()); + } + int axisH = maxRows * ROW_H; + 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; + 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, + wh[0], wh[1]}); + } + } + + // 并行分支组以 SPLIT 中心线为轴垂直居中(内层组先处理,最后整体抬回顶部 0) + centerParallelGroups(nodes, out, box); + + // 归零(2026-10-03 三视图统一新增):全体平移使 minX=minY=0,positions 消费端无需再算包围盒 + if (!box.isEmpty()) { + int minX = Integer.MAX_VALUE; + int minY = Integer.MAX_VALUE; + for (int[] b : box.values()) { + minX = Math.min(minX, b[0]); + minY = Math.min(minY, b[1]); + } + if (minX != 0 || minY != 0) { + for (int[] b : box.values()) { + b[0] -= minX; + b[1] -= minY; + } + } + } + return box; + } + + /** + * 并行分支组垂直居中:分支不再从 SPLIT 行向下堆叠,而是以 SPLIT 中心线为轴上下对称展开。 + * SPLIT↔JOIN 配对沿用编辑器口径(各出边可达集求交,取 BFS 序最早的 PARALLEL_JOIN); + * 组员 = SPLIT 到 JOIN 之间可达的全部节点;JOIN 中线吸附回 SPLIT 中线。 + * 内层组先于外层组处理(嵌套并行整体平移不破坏内层居中);结束统一把最顶行抬回 0。 + */ + private void centerParallelGroups(List nodes, Map> out, + Map box) { + class Group { + final String split; + final String join; + final List members; + final int maxX; + Group(String split, String join, List members, int maxX) { + this.split = split; + this.join = join; + this.members = members; + this.maxX = maxX; + } + } + List groups = new ArrayList<>(); + for (FlowModels.NodeVo split : nodes) { + if (!FlowDefModel.TYPE_PARALLEL_SPLIT.equals(split.getType()) || !box.containsKey(split.getId())) { + continue; + } + String sid = split.getId(); + // BFS 序 + 各分支可达集,用于定位配对 JOIN + Map order = new HashMap<>(); + order.put(sid, 0); + List bfs = new ArrayList<>(); + bfs.add(sid); + 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); + } + } + } + List heads = out.getOrDefault(sid, new ArrayList<>()); + 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 (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); + } + String join = null; + int joinOrder = Integer.MAX_VALUE; + for (FlowModels.NodeVo n : nodes) { + if (FlowDefModel.TYPE_PARALLEL_JOIN.equals(n.getType()) && box.containsKey(n.getId()) + && common.contains(n.getId())) { + int o = order.getOrDefault(n.getId(), Integer.MAX_VALUE); + if (o < joinOrder) { + joinOrder = o; + join = n.getId(); + } + } + } + if (join == null) { + continue; + } + // 组员:SPLIT 起步、不穿过 JOIN 的可达集(含嵌套 SPLIT/JOIN) + List members = new ArrayList<>(); + java.util.Set seen = new java.util.HashSet<>(); + seen.add(join); + List q = new ArrayList<>(); + q.add(sid); + while (!q.isEmpty()) { + String cur = q.remove(0); + for (String to : out.getOrDefault(cur, new ArrayList<>())) { + if (seen.contains(to) || !box.containsKey(to)) { + continue; + } + seen.add(to); + members.add(to); + q.add(to); + } + } + int maxLevel = 0; + for (String m : members) { + maxLevel = Math.max(maxLevel, box.get(m)[0]); + } + groups.add(new Group(sid, join, members, maxLevel)); + } + // maxX 降序 = 内层组先处理 + groups.sort((a, b) -> Integer.compare(b.maxX, a.maxX)); + 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; + for (String m : gp.members) { + int[] b = box.get(m); + top = Math.min(top, b[1]); + bottom = Math.max(bottom, b[1] + b[3]); + } + int delta = axisY - (top + bottom) / 2; + for (String m : gp.members) { + box.get(m)[1] += delta; + } + int[] j = box.get(gp.join); + j[1] += axisY - (j[1] + j[3] / 2); + } + // 上移出界(y<0)的组把整图平移回 0(同 delta 平移不破坏各组居中) + int minY = Integer.MAX_VALUE; + for (int[] b : box.values()) { + minY = Math.min(minY, b[1]); + } + if (minY < 0) { + for (int[] b : box.values()) { + b[1] -= minY; + } + } + } +} diff --git a/src/test/java/com/yunhai/flowcenter/service/DiagramImageServiceTest.java b/src/test/java/com/yunhai/flowcenter/service/DiagramImageServiceTest.java index 1a2a16d..e6edef1 100644 --- a/src/test/java/com/yunhai/flowcenter/service/DiagramImageServiceTest.java +++ b/src/test/java/com/yunhai/flowcenter/service/DiagramImageServiceTest.java @@ -18,7 +18,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; */ class DiagramImageServiceTest { - private final DiagramImageService service = new DiagramImageService(); + private final DiagramImageService service = new DiagramImageService(new DiagramLayoutService()); private FlowModels.NodeVo node(String id, String type, String label, String sub, String state) { FlowModels.NodeVo n = new FlowModels.NodeVo();