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