paper-burner/js/chatbot/utils/drawio-layout-optimizer.js

1187 lines
38 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// js/chatbot/utils/drawio-layout-optimizer.js
/**
* Draw.io XML 布局优化工具
*
* 参考 smart-drawio-next 的优化思路,针对 draw.io XML 格式设计
* 主要优化:
* 0. Dagre 布局 - 使用标准 Sugiyama 算法进行层次化布局(推荐,显著减少交叉)
* 1. 网格对齐 - 确保所有坐标对齐到网格
* 2. 自动间距 - 避免节点重叠和拥挤,考虑连接关系智能排列
* 3. 避免穿透 - 检测连线是否穿过节点,调整节点位置避让
* 4. 智能连接 - 优化箭头的出入点位置,移除错误的 mxPoint 子元素
* 5. 连线理线 - 检测交叉并通过调整节点位置减少交叉
* 6. 样式统一 - 统一相同类型元素的样式
*
* @version 1.3.0 - 集成 Dagre.js 标准布局算法
* @date 2025-01-16
*/
/**
* 从 XML 字符串解析出 mxCell 节点
* @param {string} xmlString - draw.io XML 字符串
* @returns {Document} 解析后的 XML 文档对象
*/
function parseDrawioXml(xmlString) {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(xmlString, 'text/xml');
// 检查解析错误
const parserError = xmlDoc.querySelector('parsererror');
if (parserError) {
throw new Error('XML 解析失败: ' + parserError.textContent);
}
return xmlDoc;
}
/**
* 将 XML 文档序列化回字符串
* @param {Document} xmlDoc - XML 文档对象
* @returns {string} XML 字符串
*/
function serializeDrawioXml(xmlDoc) {
const serializer = new XMLSerializer();
return serializer.serializeToString(xmlDoc);
}
/**
* 获取节点的几何信息
* @param {Element} cell - mxCell 元素
* @returns {Object|null} {x, y, width, height} 或 null
*/
function getCellGeometry(cell) {
const geometry = cell.querySelector('mxGeometry');
if (!geometry) return null;
return {
x: parseFloat(geometry.getAttribute('x')) || 0,
y: parseFloat(geometry.getAttribute('y')) || 0,
width: parseFloat(geometry.getAttribute('width')) || 100,
height: parseFloat(geometry.getAttribute('height')) || 100
};
}
/**
* 设置节点的几何信息
* @param {Element} cell - mxCell 元素
* @param {Object} geometry - {x, y, width, height}
*/
function setCellGeometry(cell, geometry) {
let geometryElem = cell.querySelector('mxGeometry');
if (!geometryElem) {
geometryElem = cell.ownerDocument.createElement('mxGeometry');
geometryElem.setAttribute('as', 'geometry');
cell.appendChild(geometryElem);
}
if (geometry.x !== undefined) geometryElem.setAttribute('x', geometry.x);
if (geometry.y !== undefined) geometryElem.setAttribute('y', geometry.y);
if (geometry.width !== undefined) geometryElem.setAttribute('width', geometry.width);
if (geometry.height !== undefined) geometryElem.setAttribute('height', geometry.height);
}
/**
* 网格对齐优化
* 确保所有坐标都对齐到网格(默认 10px
*
* @param {Document} xmlDoc - XML 文档对象
* @param {number} gridSize - 网格大小,默认 10
*/
function optimizeGridAlignment(xmlDoc, gridSize = 10) {
const cells = xmlDoc.querySelectorAll('mxCell[vertex="1"]');
let optimizedCount = 0;
cells.forEach(cell => {
const geometry = getCellGeometry(cell);
if (!geometry) return;
// 对齐到网格
const alignedX = Math.round(geometry.x / gridSize) * gridSize;
const alignedY = Math.round(geometry.y / gridSize) * gridSize;
if (alignedX !== geometry.x || alignedY !== geometry.y) {
setCellGeometry(cell, {
x: alignedX,
y: alignedY,
width: geometry.width,
height: geometry.height
});
optimizedCount++;
}
});
console.log(`[DrawioOptimizer] 网格对齐: ${optimizedCount} 个节点已优化`);
return optimizedCount;
}
/**
* 检测节点重叠
* @param {Object} rect1 - {x, y, width, height}
* @param {Object} rect2 - {x, y, width, height}
* @param {number} minSpacing - 最小间距
* @returns {boolean} 是否重叠或过近
*/
function isOverlapping(rect1, rect2, minSpacing = 20) {
return !(
rect1.x + rect1.width + minSpacing < rect2.x ||
rect2.x + rect2.width + minSpacing < rect1.x ||
rect1.y + rect1.height + minSpacing < rect2.y ||
rect2.y + rect2.height + minSpacing < rect1.y
);
}
/**
* 使用 Dagre 算法进行层次化布局Sugiyama 算法)
* 这是图布局的标准算法,能显著减少连线交叉
*
* @param {Document} xmlDoc - XML 文档对象
* @param {Object} options - 布局选项
* @returns {number} 调整的节点数量
*/
function applyDagreLayout(xmlDoc, options = {}) {
// 检查 dagre 和 graphlib 是否可用(它们是两个独立的全局变量)
if (typeof window.dagre === 'undefined') {
console.warn('[DrawioOptimizer] ❌ Dagre 库未加载,跳过 Dagre 布局');
return 0;
}
if (typeof window.graphlib === 'undefined') {
console.warn('[DrawioOptimizer] ❌ Graphlib 库未加载,跳过 Dagre 布局');
return 0;
}
console.log('[DrawioOptimizer] 🎯 应用 Dagre 层次化布局算法 (LR 模式)...');
const defaultOptions = {
rankdir: 'LR', // 方向LR (从左到右) - 层级横向展开,同层节点纵向排列
nodesep: 100, // 同层节点间距LR模式下是垂直间距- 增加以减少交叉
ranksep: 180, // 不同层间距LR模式下是水平间距- 增加以减少交叉
edgesep: 20, // 边之间的间距
ranker: 'network-simplex', // 使用 network-simplex 算法(最佳层分配)
marginx: 20, // 水平边距
marginy: 20 // 垂直边距
};
const opts = { ...defaultOptions, ...options };
try {
const allCells = Array.from(xmlDoc.querySelectorAll('mxCell[vertex="1"]'));
const allEdges = Array.from(xmlDoc.querySelectorAll('mxCell[edge="1"]'));
const cellMap = new Map();
// 构建 ID -> Cell 映射
xmlDoc.querySelectorAll('mxCell[id]').forEach(cell => {
cellMap.set(cell.getAttribute('id'), cell);
});
let adjustedCount = 0;
// 1. 识别 subgraph 容器swimlane和顶层节点
const subgraphContainers = [];
const topLevelCells = [];
const subgraphMembers = new Set(); // 记录 subgraph 内部节点
allCells.forEach(cell => {
const style = cell.getAttribute('style') || '';
const parent = cell.getAttribute('parent');
if (style.includes('swimlane')) {
// 这是一个 subgraph 容器
subgraphContainers.push(cell);
} else {
// 检查是否是 subgraph 内部节点
const parentCell = cellMap.get(parent);
if (parentCell && (parentCell.getAttribute('style') || '').includes('swimlane')) {
// 这是 subgraph 内部的节点
subgraphMembers.add(cell.getAttribute('id'));
} else {
// 这是顶层节点
topLevelCells.push(cell);
}
}
});
console.log(`[DrawioOptimizer] 📊 发现 ${subgraphContainers.length} 个子图, ${topLevelCells.length} 个顶层节点`);
// 2. 对每个 subgraph 内部单独进行 Dagre 布局
subgraphContainers.forEach(container => {
const containerId = container.getAttribute('id');
const containerGeo = getCellGeometry(container);
if (!containerGeo) return;
// 找出属于这个 subgraph 的所有节点
const members = allCells.filter(cell =>
cell.getAttribute('parent') === containerId
);
if (members.length === 0) return;
console.log(`[DrawioOptimizer] 🔹 子图 "${containerId}" 内部布局 (${members.length} 个节点)...`);
// 创建子图的 Dagre 图
const subG = new graphlib.Graph();
subG.setGraph({
...opts,
marginx: 20,
marginy: 30 // 顶部留空间给 swimlane 标题
});
subG.setDefaultEdgeLabel(() => ({}));
// 添加成员节点
members.forEach(cell => {
const id = cell.getAttribute('id');
const geo = getCellGeometry(cell);
if (!geo) return;
subG.setNode(id, {
width: geo.width,
height: geo.height,
originalGeo: geo,
cell: cell
});
});
// 添加子图内部的边
allEdges.forEach(edge => {
const source = edge.getAttribute('source');
const target = edge.getAttribute('target');
if (source && target && subG.hasNode(source) && subG.hasNode(target)) {
subG.setEdge(source, target);
}
});
// 执行子图布局
dagre.layout(subG);
// 应用布局结果(相对于 subgraph 容器的坐标)
// 先收集所有节点的原始坐标
const nodePositions = [];
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
subG.nodes().forEach(nodeId => {
const node = subG.node(nodeId);
if (!node) return;
const { cell, width, height } = node;
const rawX = Math.round(node.x - width / 2);
const rawY = Math.round(node.y - height / 2);
nodePositions.push({ cell, width, height, rawX, rawY });
minX = Math.min(minX, rawX);
minY = Math.min(minY, rawY);
maxX = Math.max(maxX, rawX + width);
maxY = Math.max(maxY, rawY + height);
});
console.log(`[DrawioOptimizer] 📍 原始节点范围: (${minX}, ${minY}) 到 (${maxX}, ${maxY})`);
// 计算需要的偏移量,确保节点在容器内正确位置
// swimlane 标题高度是 30px左边距至少 20px
const targetMinX = 20; // 左边距
const targetMinY = 40; // 标题栏30px + 顶部边距10px
// 只在节点坐标异常时才应用偏移量
// 如果节点已经在合理位置(正数且接近目标),保持原位
let offsetX = 0;
let offsetY = 0;
// 只在节点出现负坐标或严重偏移时才修正
if (minX < 0) {
offsetX = targetMinX - minX; // 修正负坐标
} else if (minX < 10) {
offsetX = targetMinX - minX; // 太靠近边缘,加点边距
}
if (minY < 0) {
offsetY = targetMinY - minY; // 修正负坐标
} else if (minY < 30) {
offsetY = targetMinY - minY; // 太靠近标题栏,加点边距
}
console.log(`[DrawioOptimizer] 📐 应用偏移量: (${offsetX}, ${offsetY}) ${offsetX === 0 && offsetY === 0 ? '(无需调整)' : ''}`);
// 应用偏移后的坐标
let finalMinX = Infinity, finalMinY = Infinity, finalMaxX = -Infinity, finalMaxY = -Infinity;
nodePositions.forEach(({ cell, width, height, rawX, rawY }) => {
const finalX = rawX + offsetX;
const finalY = rawY + offsetY;
finalMinX = Math.min(finalMinX, finalX);
finalMinY = Math.min(finalMinY, finalY);
finalMaxX = Math.max(finalMaxX, finalX + width);
finalMaxY = Math.max(finalMaxY, finalY + height);
setCellGeometry(cell, {
x: finalX,
y: finalY,
width: width,
height: height
});
adjustedCount++;
});
console.log(`[DrawioOptimizer] ✅ 最终节点范围: (${finalMinX}, ${finalMinY}) 到 (${finalMaxX}, ${finalMaxY})`);
// 调整 subgraph 容器大小以包含所有成员
if (finalMinX !== Infinity && finalMinY !== Infinity) {
// 容器需要的尺寸 = 节点最大范围 + 底部/右侧边距
const requiredWidth = Math.ceil(finalMaxX + 20); // 右侧留20px边距
const requiredHeight = Math.ceil(finalMaxY + 20); // 底部留20px边距
setCellGeometry(container, {
x: containerGeo.x,
y: containerGeo.y,
width: Math.max(requiredWidth, 300), // 最小300px
height: Math.max(requiredHeight, 200) // 最小200px
});
console.log(`[DrawioOptimizer] 📏 容器调整: ${requiredWidth}x${requiredHeight}`);
}
});
// 3. 对顶层节点 + subgraph 容器进行全局 Dagre 布局
if (topLevelCells.length > 0 || subgraphContainers.length > 0) {
console.log(`[DrawioOptimizer] 🌍 全局布局 (${topLevelCells.length + subgraphContainers.length} 个顶层元素)...`);
const g = new graphlib.Graph();
g.setGraph(opts);
g.setDefaultEdgeLabel(() => ({}));
// 添加顶层节点和 subgraph 容器
[...topLevelCells, ...subgraphContainers].forEach(cell => {
const id = cell.getAttribute('id');
const geo = getCellGeometry(cell);
if (!geo) return;
g.setNode(id, {
width: geo.width,
height: geo.height,
originalGeo: geo,
cell: cell
});
});
// 添加顶层的边(不包括 subgraph 内部的边)
allEdges.forEach(edge => {
const source = edge.getAttribute('source');
const target = edge.getAttribute('target');
// 只添加至少有一端是顶层节点的边
if (source && target && g.hasNode(source) && g.hasNode(target)) {
g.setEdge(source, target);
}
});
// 执行全局布局
dagre.layout(g);
// 应用全局布局结果
g.nodes().forEach(nodeId => {
const node = g.node(nodeId);
if (!node) return;
const { cell, width, height } = node;
const newX = Math.round(node.x - width / 2);
const newY = Math.round(node.y - height / 2);
const geo = getCellGeometry(cell);
if (!geo) return;
// 直接设置容器位置
// 注意subgraph 容器内的节点坐标是相对的,容器移动时会自动跟随,不需要单独调整
setCellGeometry(cell, {
x: newX,
y: newY,
width: width,
height: height
});
adjustedCount++;
});
}
console.log(`[DrawioOptimizer] ✅ Dagre 布局完成: ${adjustedCount} 个节点已优化`);
return adjustedCount;
} catch (error) {
console.error('[DrawioOptimizer] ❌ Dagre 布局失败:', error);
return 0;
}
}
/**
* 自动间距优化(增强版 - 考虑连线关系)
* 检测并修复节点重叠问题,同时尽量减少连线交叉
*
* @param {Document} xmlDoc - XML 文档对象
* @param {number} minSpacing - 最小间距,默认 30px
*/
function optimizeSpacing(xmlDoc, minSpacing = 30) {
const cells = Array.from(xmlDoc.querySelectorAll('mxCell[vertex="1"]'));
const geometries = cells.map(cell => ({
cell,
id: cell.getAttribute('id'),
...getCellGeometry(cell)
})).filter(g => g.x !== undefined);
// 构建连接关系图
const edges = xmlDoc.querySelectorAll('mxCell[edge="1"]');
const connections = new Map(); // nodeId -> [targetIds]
edges.forEach(edge => {
const sourceId = edge.getAttribute('source');
const targetId = edge.getAttribute('target');
if (sourceId && targetId) {
if (!connections.has(sourceId)) {
connections.set(sourceId, []);
}
connections.get(sourceId).push(targetId);
}
});
let adjustedCount = 0;
// 按 Y 坐标分层
const layers = new Map();
geometries.forEach(g => {
const layerY = Math.round(g.y / 50) * 50; // 按 50px 分层
if (!layers.has(layerY)) {
layers.set(layerY, []);
}
layers.get(layerY).push(g);
});
// 对每一层进行排序优化,减少交叉
layers.forEach((nodesInLayer, layerY) => {
if (nodesInLayer.length <= 1) return;
// 按连接关系排序:如果节点有共同的目标,应该相邻放置
nodesInLayer.sort((a, b) => {
const aTargets = connections.get(a.id) || [];
const bTargets = connections.get(b.id) || [];
// 如果有共同目标,按第一个目标的 X 坐标排序
if (aTargets.length > 0 && bTargets.length > 0) {
// 简化:按第一个目标的 ID 字母序排序
return aTargets[0].localeCompare(bTargets[0]);
}
// 否则按当前 X 坐标排序
return a.x - b.x;
});
// 重新分配 X 坐标,保持间距
let currentX = nodesInLayer[0].x;
nodesInLayer.forEach((g, index) => {
if (index > 0) {
currentX += nodesInLayer[index - 1].width + minSpacing;
}
if (Math.abs(g.x - currentX) > 5) {
setCellGeometry(g.cell, {
x: currentX,
y: g.y,
width: g.width,
height: g.height
});
adjustedCount++;
}
g.x = currentX;
});
});
// 传统的重叠检测(跨层)
for (let i = 0; i < geometries.length; i++) {
for (let j = i + 1; j < geometries.length; j++) {
const g1 = geometries[i];
const g2 = geometries[j];
// 如果在不同层,跳过(已经在上面处理了)
const layer1 = Math.round(g1.y / 50) * 50;
const layer2 = Math.round(g2.y / 50) * 50;
if (layer1 === layer2) continue;
if (isOverlapping(g1, g2, minSpacing)) {
// 横向推开第二个节点
const newX = g1.x + g1.width + minSpacing;
setCellGeometry(g2.cell, {
x: newX,
y: g2.y,
width: g2.width,
height: g2.height
});
g2.x = newX; // 更新缓存
adjustedCount++;
}
}
}
console.log(`[DrawioOptimizer] 间距优化: ${adjustedCount} 个节点已调整`);
return adjustedCount;
}
/**
* 计算两个节点的最佳连接边缘
* 根据相对位置判断应该从哪条边连接
*
* 对于 LR 布局(从左到右):强制从左右边连接,不使用上下边
*
* @param {Object} sourceGeometry - 源节点几何信息
* @param {Object} targetGeometry - 目标节点几何信息
* @param {string} layoutDirection - 布局方向:'LR' 或 'TB',默认 'LR'
* @returns {Object} {exitX, exitY, entryX, entryY} - 归一化坐标 (0-1)
*/
function calculateOptimalConnection(sourceGeometry, targetGeometry, layoutDirection = 'LR') {
if (!sourceGeometry || !targetGeometry) {
return { exitX: 0.5, exitY: 0.5, entryX: 0.5, entryY: 0.5 };
}
// 计算中心点
const sourceCenterX = sourceGeometry.x + sourceGeometry.width / 2;
const sourceCenterY = sourceGeometry.y + sourceGeometry.height / 2;
const targetCenterX = targetGeometry.x + targetGeometry.width / 2;
const targetCenterY = targetGeometry.y + targetGeometry.height / 2;
// 计算相对位置
const dx = targetCenterX - sourceCenterX;
const dy = targetCenterY - sourceCenterY;
let exitX = 0.5, exitY = 0.5, entryX = 0.5, entryY = 0.5;
if (layoutDirection === 'LR') {
// LR 布局:强制使用左右边连接
if (dx > 0) {
// 目标在右侧 - 标准流向
exitX = 1; exitY = 0.5; // 从右边出发
entryX = 0; entryY = 0.5; // 从左边进入
} else {
// 目标在左侧 - 反向连接
exitX = 0; exitY = 0.5; // 从左边出发
entryX = 1; entryY = 0.5; // 从右边进入
}
} else {
// TB 布局:根据相对位置自动选择
const isHorizontal = Math.abs(dx) > Math.abs(dy);
if (isHorizontal) {
if (dx > 0) {
exitX = 1; exitY = 0.5;
entryX = 0; entryY = 0.5;
} else {
exitX = 0; exitY = 0.5;
entryX = 1; entryY = 0.5;
}
} else {
if (dy > 0) {
exitX = 0.5; exitY = 1;
entryX = 0.5; entryY = 0;
} else {
exitX = 0.5; exitY = 0;
entryX = 0.5; entryY = 1;
}
}
}
return { exitX, exitY, entryX, entryY };
}
/**
* 检测两条连线是否交叉
* @param {Object} edge1 - {source, target, sourceGeo, targetGeo}
* @param {Object} edge2 - {source, target, sourceGeo, targetGeo}
* @returns {boolean} 是否交叉
*/
function detectEdgeCrossing(edge1, edge2) {
// 简化:检测线段的包围盒是否重叠
const box1 = {
minX: Math.min(edge1.sourceGeo.x, edge1.targetGeo.x),
maxX: Math.max(edge1.sourceGeo.x + edge1.sourceGeo.width, edge1.targetGeo.x + edge1.targetGeo.width),
minY: Math.min(edge1.sourceGeo.y, edge1.targetGeo.y),
maxY: Math.max(edge1.sourceGeo.y + edge1.sourceGeo.height, edge1.targetGeo.y + edge1.targetGeo.height)
};
const box2 = {
minX: Math.min(edge2.sourceGeo.x, edge2.targetGeo.x),
maxX: Math.max(edge2.sourceGeo.x + edge2.sourceGeo.width, edge2.targetGeo.x + edge2.targetGeo.width),
minY: Math.min(edge2.sourceGeo.y, edge2.targetGeo.y),
maxY: Math.max(edge2.sourceGeo.y + edge2.sourceGeo.height, edge2.targetGeo.y + edge2.targetGeo.height)
};
// 包围盒重叠检测
const overlapping = !(box1.maxX < box2.minX || box2.maxX < box1.minX ||
box1.maxY < box2.minY || box2.maxY < box1.minY);
if (!overlapping) return false;
// 进一步检测:如果是垂直布局(上下关系),检查是否交叉连接
const edge1IsVertical = Math.abs(edge1.targetGeo.y - edge1.sourceGeo.y) > 50;
const edge2IsVertical = Math.abs(edge2.targetGeo.y - edge2.sourceGeo.y) > 50;
if (edge1IsVertical && edge2IsVertical) {
// 检查交叉连接模式A→C 和 B→D如果 A 在 B 右侧但 C 在 D 左侧
const edge1SourceCenter = edge1.sourceGeo.x + edge1.sourceGeo.width / 2;
const edge1TargetCenter = edge1.targetGeo.x + edge1.targetGeo.width / 2;
const edge2SourceCenter = edge2.sourceGeo.x + edge2.sourceGeo.width / 2;
const edge2TargetCenter = edge2.targetGeo.x + edge2.targetGeo.width / 2;
// 交叉模式检测
if ((edge1SourceCenter > edge2SourceCenter && edge1TargetCenter < edge2TargetCenter) ||
(edge1SourceCenter < edge2SourceCenter && edge1TargetCenter > edge2TargetCenter)) {
return true;
}
}
return false;
}
/**
* 避免连线穿过节点的优化
* 检测连线路径是否穿过其他节点,并调整节点位置来避让
*
* @param {Document} xmlDoc - XML 文档对象
* @returns {number} 调整的节点数量
*/
function optimizeEdgeNodeAvoidance(xmlDoc) {
console.log('[DrawioOptimizer] 🎯 检测连线-节点冲突...');
const edges = xmlDoc.querySelectorAll('mxCell[edge="1"]');
const vertices = Array.from(xmlDoc.querySelectorAll('mxCell[vertex="1"]'));
const cellMap = new Map();
// 构建 ID -> Cell 的映射
xmlDoc.querySelectorAll('mxCell[id]').forEach(cell => {
cellMap.set(cell.getAttribute('id'), cell);
});
// 收集所有节点的几何信息
const nodeGeometries = vertices.map(cell => ({
cell,
id: cell.getAttribute('id'),
...getCellGeometry(cell)
})).filter(g => g.x !== undefined);
let adjustedCount = 0;
// 检测每条连线
edges.forEach(edge => {
const sourceId = edge.getAttribute('source');
const targetId = edge.getAttribute('target');
if (!sourceId || !targetId) return;
const sourceCell = cellMap.get(sourceId);
const targetCell = cellMap.get(targetId);
if (!sourceCell || !targetCell) return;
const sourceGeo = getCellGeometry(sourceCell);
const targetGeo = getCellGeometry(targetCell);
if (!sourceGeo || !targetGeo) return;
// 计算连线的包围盒(简化的正交路径)
// 正交路径source中心 → 垂直移动 → 水平移动 → 垂直移动 → target中心
const sourceCenterX = sourceGeo.x + sourceGeo.width / 2;
const sourceCenterY = sourceGeo.y + sourceGeo.height / 2;
const targetCenterX = targetGeo.x + targetGeo.width / 2;
const targetCenterY = targetGeo.y + targetGeo.height / 2;
// 连线的包围盒留10px余量
const edgeBox = {
minX: Math.min(sourceCenterX, targetCenterX) - 10,
maxX: Math.max(sourceCenterX, targetCenterX) + 10,
minY: Math.min(sourceCenterY, targetCenterY) - 10,
maxY: Math.max(sourceCenterY, targetCenterY) + 10
};
// 检测是否有其他节点在连线路径上
nodeGeometries.forEach(node => {
// 跳过连线的起点和终点
if (node.id === sourceId || node.id === targetId) return;
// 检测节点是否在连线的包围盒内
const nodeBox = {
minX: node.x,
maxX: node.x + node.width,
minY: node.y,
maxY: node.y + node.height
};
// 包围盒相交检测
const isIntersecting = !(
nodeBox.maxX < edgeBox.minX ||
nodeBox.minX > edgeBox.maxX ||
nodeBox.maxY < edgeBox.minY ||
nodeBox.minY > edgeBox.minY
);
if (isIntersecting) {
// 检测节点是否在连线的"中间区域"(不是起点或终点附近)
const isInMiddleRegion =
nodeBox.minX > Math.min(sourceGeo.x + sourceGeo.width, targetGeo.x + targetGeo.width) &&
nodeBox.maxX < Math.max(sourceGeo.x, targetGeo.x);
if (isInMiddleRegion) {
// 需要调整节点位置
// 策略:将节点向左或向右移动,偏离连线路径
const edgeCenterX = (sourceCenterX + targetCenterX) / 2;
const nodeCenterX = node.x + node.width / 2;
// 计算移动方向(远离连线中心)
let newX;
if (nodeCenterX < edgeCenterX) {
// 节点在连线左侧,继续向左移
newX = edgeBox.minX - node.width - 30;
} else {
// 节点在连线右侧,继续向右移
newX = edgeBox.maxX + 30;
}
// 确保新位置不是负数
newX = Math.max(0, newX);
// 更新节点位置
setCellGeometry(node.cell, {
x: newX,
y: node.y,
width: node.width,
height: node.height
});
node.x = newX; // 更新缓存
adjustedCount++;
console.log(`[DrawioOptimizer] 调整节点 ${node.id},避让连线 ${sourceId}->${targetId}`);
}
}
});
});
console.log(`[DrawioOptimizer] ✅ 连线-节点避让: ${adjustedCount} 个节点已调整`);
return adjustedCount;
}
/**
* 智能连接优化(增强版 - 包含理线功能)
* 自动设置连接线的出入点,并尝试减少交叉
*
* @param {Document} xmlDoc - XML 文档对象
* @param {string} layoutDirection - 布局方向:'LR' 或 'TB',默认 'LR'
*/
function optimizeConnections(xmlDoc, layoutDirection = 'LR') {
const edges = xmlDoc.querySelectorAll('mxCell[edge="1"]');
const cellMap = new Map();
// 构建 ID -> Cell 的映射
xmlDoc.querySelectorAll('mxCell[id]').forEach(cell => {
cellMap.set(cell.getAttribute('id'), cell);
});
// 收集所有连线信息
const edgeInfos = [];
edges.forEach(edge => {
const sourceId = edge.getAttribute('source');
const targetId = edge.getAttribute('target');
if (!sourceId || !targetId) return;
const sourceCell = cellMap.get(sourceId);
const targetCell = cellMap.get(targetId);
if (!sourceCell || !targetCell) return;
const sourceGeo = getCellGeometry(sourceCell);
const targetGeo = getCellGeometry(targetCell);
if (!sourceGeo || !targetGeo) return;
edgeInfos.push({
edge,
sourceId,
targetId,
sourceCell,
targetCell,
sourceGeo,
targetGeo
});
});
// 检测交叉并记录
let crossingCount = 0;
const crossingPairs = [];
for (let i = 0; i < edgeInfos.length; i++) {
for (let j = i + 1; j < edgeInfos.length; j++) {
if (detectEdgeCrossing(edgeInfos[i], edgeInfos[j])) {
crossingCount++;
crossingPairs.push([i, j]);
}
}
}
if (crossingCount > 0) {
console.log(`[DrawioOptimizer] 检测到 ${crossingCount} 处连线交叉,尝试优化...`);
}
let optimizedCount = 0;
// 为每条连线设置最佳连接点
edgeInfos.forEach(info => {
const { edge, sourceGeo, targetGeo } = info;
// 计算最佳连接点(传入布局方向)
const connection = calculateOptimalConnection(sourceGeo, targetGeo, layoutDirection);
// 获取或创建 mxGeometry必须是自闭合标签不能有子元素
let geometry = edge.querySelector('mxGeometry');
if (!geometry) {
geometry = xmlDoc.createElement('mxGeometry');
geometry.setAttribute('relative', '1');
geometry.setAttribute('as', 'geometry');
edge.appendChild(geometry);
}
// ❌ 删除任何 mxPoint 子元素(这会导致 "Could not add object mxGeometry" 错误)
const mxPoints = geometry.querySelectorAll('mxPoint');
mxPoints.forEach(point => point.remove());
// ✅ 连接点信息应该只放在 style 属性中,不要创建 mxPoint 子元素
// 更新样式:添加 exitX/exitY/entryX/entryY
const style = edge.getAttribute('style') || '';
const styleMap = new Map();
style.split(';').forEach(pair => {
const [key, value] = pair.split('=');
if (key) styleMap.set(key.trim(), value || '');
});
styleMap.set('exitX', connection.exitX);
styleMap.set('exitY', connection.exitY);
styleMap.set('entryX', connection.entryX);
styleMap.set('entryY', connection.entryY);
// 添加正交路由样式(美观且减少交叉)
if (!styleMap.has('edgeStyle')) {
styleMap.set('edgeStyle', 'orthogonalEdgeStyle');
}
if (!styleMap.has('rounded')) {
styleMap.set('rounded', '0');
}
const newStyle = Array.from(styleMap.entries())
.map(([k, v]) => v ? `${k}=${v}` : k)
.join(';');
edge.setAttribute('style', newStyle);
optimizedCount++;
});
console.log(`[DrawioOptimizer] 连接优化: ${optimizedCount} 条连接线已优化`);
return optimizedCount;
}
/**
* 样式统一优化
* 为相同类型的节点应用一致的样式
*
* @param {Document} xmlDoc - XML 文档对象
*/
function optimizeStyles(xmlDoc) {
const cells = xmlDoc.querySelectorAll('mxCell[vertex="1"]');
// 按节点类型分组(根据 style 中的 shape 或默认形状)
const typeGroups = new Map();
cells.forEach(cell => {
const style = cell.getAttribute('style') || '';
let type = 'default';
// 提取形状类型
const shapeMatch = style.match(/shape=([^;]+)/);
if (shapeMatch) {
type = shapeMatch[1];
} else if (style.includes('rounded=1')) {
type = 'rounded';
} else if (style.includes('ellipse')) {
type = 'ellipse';
}
if (!typeGroups.has(type)) {
typeGroups.set(type, []);
}
typeGroups.get(type).push(cell);
});
let optimizedCount = 0;
// 为每个类型组应用统一样式
typeGroups.forEach((cells, type) => {
if (cells.length < 2) return; // 少于2个节点无需统一
// 收集第一个节点的样式作为基准
const referenceStyle = cells[0].getAttribute('style') || '';
const styleMap = new Map();
referenceStyle.split(';').forEach(pair => {
const [key, value] = pair.split('=');
if (key) styleMap.set(key.trim(), value || '');
});
// 确保有基本样式
if (!styleMap.has('fillColor')) {
// 根据类型设置默认颜色
const colors = {
'default': '#dae8fc',
'rounded': '#d5e8d4',
'ellipse': '#ffe6cc',
'swimlane': '#f5f5f5'
};
styleMap.set('fillColor', colors[type] || '#ffffff');
}
if (!styleMap.has('strokeColor')) {
styleMap.set('strokeColor', '#6c8ebf');
}
// 应用到所有同类型节点
const unifiedStyle = Array.from(styleMap.entries())
.map(([k, v]) => v ? `${k}=${v}` : k)
.join(';');
cells.forEach((cell, index) => {
if (index === 0) return; // 跳过参考节点
cell.setAttribute('style', unifiedStyle);
optimizedCount++;
});
});
console.log(`[DrawioOptimizer] 样式统一: ${optimizedCount} 个节点已优化`);
return optimizedCount;
}
/**
* 主优化函数
* 依次执行所有优化步骤
*
* @param {string} xmlString - 原始 draw.io XML 字符串
* @param {Object} options - 优化选项
* @param {boolean} options.gridAlignment - 是否网格对齐,默认 true
* @param {boolean} options.spacing - 是否间距优化,默认 true
* @param {boolean} options.connections - 是否连接优化,默认 true
* @param {boolean} options.styles - 是否样式统一,默认 false
* @returns {string} 优化后的 XML 字符串
*/
function optimizeDrawioLayout(xmlString, options = {}) {
const defaultOptions = {
dagreLayout: true, // 使用 Dagre 算法进行层次化布局(新增,默认开启)
gridAlignment: true,
spacing: true,
connections: true,
styles: false // 默认关闭,避免覆盖用户自定义样式
};
const opts = { ...defaultOptions, ...options };
try {
console.log('[DrawioOptimizer] 开始优化布局...');
// 解析 XML
const xmlDoc = parseDrawioXml(xmlString);
let totalOptimized = 0;
// 0. Dagre 层次化布局(优先级最高,使用标准 Sugiyama 算法)
if (opts.dagreLayout) {
totalOptimized += applyDagreLayout(xmlDoc, {
rankdir: 'LR', // 从左到右布局,层级横向展开
nodesep: 100, // 同层节点垂直间距 - 增加以减少交叉
ranksep: 180, // 不同层水平间距 - 增加以减少交叉
edgesep: 20, // 边之间的间距
ranker: 'network-simplex' // 最佳层分配算法
});
}
// 1. 网格对齐
if (opts.gridAlignment) {
totalOptimized += optimizeGridAlignment(xmlDoc, 10);
}
// 2. 间距优化(如果没有使用 dagre则进行间距优化
if (opts.spacing && !opts.dagreLayout) {
totalOptimized += optimizeSpacing(xmlDoc, 30);
}
// 3. 避免连线穿过节点
if (opts.spacing) {
totalOptimized += optimizeEdgeNodeAvoidance(xmlDoc);
}
// 4. 连接优化(传入布局方向)
if (opts.connections) {
totalOptimized += optimizeConnections(xmlDoc, 'LR');
}
// 5. 样式统一
if (opts.styles) {
totalOptimized += optimizeStyles(xmlDoc);
}
console.log(`[DrawioOptimizer] ✅ 优化完成,共优化 ${totalOptimized}`);
// 序列化回 XML
return serializeDrawioXml(xmlDoc);
} catch (error) {
console.error('[DrawioOptimizer] ❌ 优化失败:', error);
return xmlString; // 失败时返回原始 XML
}
}
/**
* 对单个 diagram 进行优化(多页支持)
* @param {Element} diagram - diagram 元素
* @param {Object} opts - 优化选项
* @returns {number} 优化次数
*/
function optimizeDiagram(diagram, opts) {
const diagramName = diagram.getAttribute('name') || 'Unnamed';
// 创建临时文档,只包含当前 diagram
const tempDoc = document.implementation.createDocument(null, 'mxfile', null);
const tempDiagram = diagram.cloneNode(true);
tempDoc.documentElement.appendChild(tempDiagram);
let optimized = 0;
// 获取布局方向(默认 TB
const layoutDir = opts.layoutDirection || 'TB';
// 应用所有优化(使用临时文档)
if (opts.dagreLayout) {
// 根据布局方向调整参数
const dagreOpts = layoutDir === 'TB' ? {
rankdir: 'TB', // 从上到下
nodesep: 80, // 同层节点横向间距
ranksep: 120, // 不同层纵向间距(更紧凑)
edgesep: 10,
ranker: 'network-simplex'
} : {
rankdir: 'LR', // 从左到右
nodesep: 100, // 同层节点纵向间距
ranksep: 180, // 不同层横向间距
edgesep: 20,
ranker: 'network-simplex'
};
optimized += applyDagreLayout(tempDoc, dagreOpts);
}
if (opts.gridAlignment) {
optimized += optimizeGridAlignment(tempDoc, 10);
}
if (opts.spacing && !opts.dagreLayout) {
optimized += optimizeSpacing(tempDoc, 30);
}
if (opts.spacing) {
optimized += optimizeEdgeNodeAvoidance(tempDoc);
}
if (opts.connections) {
optimized += optimizeConnections(tempDoc, layoutDir);
}
if (opts.styles) {
optimized += optimizeStyles(tempDoc);
}
// 将优化后的节点同步回原始 diagram
const optimizedDiagram = tempDoc.querySelector('diagram');
const originalCells = diagram.querySelectorAll('mxCell[id]');
const optimizedCells = optimizedDiagram.querySelectorAll('mxCell[id]');
const cellMap = new Map();
optimizedCells.forEach(cell => {
cellMap.set(cell.getAttribute('id'), cell);
});
originalCells.forEach(originalCell => {
const id = originalCell.getAttribute('id');
const optimizedCell = cellMap.get(id);
if (!optimizedCell) return;
// 同步几何信息和样式
const originalGeo = originalCell.querySelector('mxGeometry');
const optimizedGeo = optimizedCell.querySelector('mxGeometry');
if (originalGeo && optimizedGeo) {
// 同步所有属性
['x', 'y', 'width', 'height', 'relative', 'as', 'exitX', 'exitY', 'entryX', 'entryY'].forEach(attr => {
if (optimizedGeo.hasAttribute(attr)) {
originalGeo.setAttribute(attr, optimizedGeo.getAttribute(attr));
}
});
}
// 同步样式
if (optimizedCell.hasAttribute('style')) {
originalCell.setAttribute('style', optimizedCell.getAttribute('style'));
}
});
return optimized;
}
/**
* 主优化函数(支持多页图表)
* @param {string} xmlString - 原始 draw.io XML 字符串
* @param {Object} options - 优化选项
* @returns {string} 优化后的 XML 字符串
*/
function optimizeDrawioLayoutMultiPage(xmlString, options = {}) {
const defaultOptions = {
dagreLayout: true,
gridAlignment: true,
spacing: true,
connections: true,
styles: false
};
const opts = { ...defaultOptions, ...options };
try {
const xmlDoc = parseDrawioXml(xmlString);
const diagrams = Array.from(xmlDoc.querySelectorAll('diagram'));
if (diagrams.length === 0) {
console.warn('[DrawioOptimizer] ⚠️ 未找到 diagram 元素,使用旧版单页优化');
return optimizeDrawioLayout(xmlString, options);
}
console.log(`[DrawioOptimizer] 🎯 检测到 ${diagrams.length} 个页面,开始独立优化...`);
let totalOptimized = 0;
diagrams.forEach((diagram, index) => {
const name = diagram.getAttribute('name') || `Page ${index + 1}`;
console.log(`[DrawioOptimizer] 📄 优化页面 "${name}"...`);
const count = optimizeDiagram(diagram, opts);
console.log(`[DrawioOptimizer] ✅ 页面 "${name}" 完成,优化 ${count}`);
totalOptimized += count;
});
console.log(`[DrawioOptimizer] ✅ 全部完成,共优化 ${totalOptimized}`);
return serializeDrawioXml(xmlDoc);
} catch (error) {
console.error('[DrawioOptimizer] ❌ 多页优化失败,回退到单页模式:', error);
return optimizeDrawioLayout(xmlString, options);
}
}
// 导出到全局
window.DrawioLayoutOptimizer = {
optimizeDrawioLayout: optimizeDrawioLayoutMultiPage, // 使用新的多页版本
optimizeDrawioLayoutLegacy: optimizeDrawioLayout // 保留旧版本
};