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();