Improve route flow responsive layout
This commit is contained in:
parent
be1d702334
commit
a1fda76330
|
|
@ -7,12 +7,21 @@ export const NODE_H = 54;
|
|||
* dagre 自动布局,返回带 position 的 nodes
|
||||
* @param {Array} nodes Vue Flow 节点(id 必须为字符串)
|
||||
* @param {Array} edges Vue Flow 边
|
||||
* @param {Object} opts { rankdir: TB|BT|LR|RL, nodeW, nodeH }
|
||||
* @param {Object} opts { rankdir: TB|BT|LR|RL, nodeW, nodeH, nodesep, edgesep, ranksep, marginx, marginy }
|
||||
*/
|
||||
export function layoutGraph(nodes, edges, opts = {}) {
|
||||
const { rankdir = "TB", nodeW = NODE_W, nodeH = NODE_H } = opts;
|
||||
const {
|
||||
rankdir = "TB",
|
||||
nodeW = NODE_W,
|
||||
nodeH = NODE_H,
|
||||
nodesep = 30,
|
||||
edgesep = 20,
|
||||
ranksep = 56,
|
||||
marginx = 20,
|
||||
marginy = 20,
|
||||
} = opts;
|
||||
const g = new dagre.graphlib.Graph();
|
||||
g.setGraph({ rankdir, nodesep: 30, ranksep: 56, marginx: 20, marginy: 20 });
|
||||
g.setGraph({ rankdir, nodesep, edgesep, ranksep, marginx, marginy });
|
||||
g.setDefaultEdgeLabel(() => ({}));
|
||||
nodes.forEach((n) => g.setNode(n.id, { width: nodeW, height: nodeH }));
|
||||
edges.forEach((e) => g.setEdge(e.source, e.target));
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
<div class="flow-toolbar__left">
|
||||
<el-button v-if="!readonly" type="primary" icon="el-icon-plus" size="small" round @click="addStep">新增工序</el-button>
|
||||
<el-button icon="el-icon-magic-stick" size="small" round @click="relayout">自动布局</el-button>
|
||||
<el-button icon="el-icon-full-screen" size="small" round @click="doFit">适应画布</el-button>
|
||||
<el-button icon="el-icon-full-screen" size="small" round @click="fitToWidth">适应宽度</el-button>
|
||||
<el-button icon="el-icon-circle-check" size="small" round @click="doValidate" :loading="validating">校验</el-button>
|
||||
</div>
|
||||
<div class="flow-legend">
|
||||
|
|
@ -55,12 +55,14 @@
|
|||
</div>
|
||||
|
||||
<!-- 画布 -->
|
||||
<div class="flow-canvas" @drop="onDrop" @dragover.prevent>
|
||||
<div ref="canvas" class="flow-canvas" @drop="onDrop" @dragover.prevent>
|
||||
<VueFlow
|
||||
:id="flowId"
|
||||
:default-viewport="{ zoom: 0.9 }"
|
||||
:min-zoom="0.2"
|
||||
:max-zoom="2"
|
||||
:pan-on-scroll="true"
|
||||
:zoom-on-scroll="false"
|
||||
:connection-radius="8"
|
||||
:connect-on-click="false"
|
||||
:nodes-draggable="true"
|
||||
|
|
@ -109,7 +111,7 @@ import { Background } from "@vue-flow/background";
|
|||
import { Controls } from "@vue-flow/controls";
|
||||
import routeForm from "@/views/mtm/route_form.vue";
|
||||
import MaterialNode from "./flow/MaterialNode.vue";
|
||||
import { layoutGraph } from "./flow/dagreLayout";
|
||||
import { layoutGraph, NODE_W } from "./flow/dagreLayout";
|
||||
import "@vue-flow/core/dist/style.css";
|
||||
import "@vue-flow/core/dist/theme-default.css";
|
||||
import "@vue-flow/controls/dist/style.css";
|
||||
|
|
@ -132,9 +134,9 @@ export default {
|
|||
emits: ["changed"],
|
||||
setup() {
|
||||
const flowId = "route-flow-editor-" + ++flowSeq;
|
||||
const { fitView, screenToFlowCoordinate, project, setNodes, setEdges, addNodes, removeNodes } =
|
||||
const { setViewport, screenToFlowCoordinate, project, setNodes, setEdges, addNodes, removeNodes } =
|
||||
useVueFlow(flowId);
|
||||
return { flowId, fitView, screenToFlowCoordinate, project, setNodes, setEdges, addNodes, removeNodes };
|
||||
return { flowId, setViewport, screenToFlowCoordinate, project, setNodes, setEdges, addNodes, removeNodes };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
|
@ -160,6 +162,8 @@ export default {
|
|||
connectStartPos: null,
|
||||
// 仅初次加载自动适配一次,避免编辑时视图跳动
|
||||
hasFitted: false,
|
||||
resizeObserver: null,
|
||||
resizeTimer: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
|
|
@ -180,6 +184,19 @@ export default {
|
|||
mounted() {
|
||||
this.loadMaterials();
|
||||
if (this.routepack) this.reload();
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
clearTimeout(this.resizeTimer);
|
||||
this.resizeTimer = setTimeout(() => {
|
||||
if (this.hasFitted) this.fitToWidth(false);
|
||||
}, 80);
|
||||
});
|
||||
this.resizeObserver.observe(this.$refs.canvas);
|
||||
}
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.resizeObserver) this.resizeObserver.disconnect();
|
||||
clearTimeout(this.resizeTimer);
|
||||
},
|
||||
methods: {
|
||||
/** 加载左侧物料面板列表 */
|
||||
|
|
@ -234,6 +251,7 @@ export default {
|
|||
const matKind = {};
|
||||
const targetIds = new Set();
|
||||
const edges = [];
|
||||
const parallelEdgeCount = {};
|
||||
|
||||
this.routes.forEach((r) => {
|
||||
if (r.material_in == null || r.material_out == null) return;
|
||||
|
|
@ -246,17 +264,21 @@ export default {
|
|||
targetIds.add(tid);
|
||||
|
||||
const locked = !!r.from_route;
|
||||
const edgeKey = sid + "->" + tid;
|
||||
const edgeIndex = parallelEdgeCount[edgeKey] || 0;
|
||||
parallelEdgeCount[edgeKey] = edgeIndex + 1;
|
||||
edges.push({
|
||||
id: String(r.id),
|
||||
source: sid,
|
||||
target: tid,
|
||||
label: (r.process_name || "") + (locked ? " 🔒" : ""),
|
||||
data: { route: r, locked },
|
||||
type: "smoothstep",
|
||||
type: "default",
|
||||
pathOptions: { curvature: 0.25 + edgeIndex * 0.12 },
|
||||
markerEnd: "arrowclosed",
|
||||
style: {
|
||||
stroke: locked ? "#c0c4cc" : "#409eff",
|
||||
strokeWidth: locked ? 1.5 : 2,
|
||||
strokeWidth: locked ? 1.7 : 2.2,
|
||||
strokeDasharray: locked ? "5 4" : undefined,
|
||||
},
|
||||
labelStyle: { fill: "#5b6370", fontSize: "12px", fontWeight: 500 },
|
||||
|
|
@ -291,10 +313,21 @@ export default {
|
|||
|
||||
const prevCount = this.nodes.length;
|
||||
this.edges = edges;
|
||||
this.nodes = layoutGraph(routeNodes.concat(this.seedNodes), edges);
|
||||
this.nodes = this.layoutNodes(routeNodes.concat(this.seedNodes), edges);
|
||||
this.syncStore();
|
||||
// 节点增减后 dagre 会整体重排,重新适配视野,避免“看着是空白其实有节点”导致拉线误吸附
|
||||
if (this.hasFitted && this.nodes.length !== prevCount) this.doFit();
|
||||
if (this.hasFitted && this.nodes.length !== prevCount) this.fitToWidth();
|
||||
},
|
||||
|
||||
/** 编辑与查看使用一致的疏朗布局参数,给工序标签和分支留出空间 */
|
||||
layoutNodes(nodes, edges) {
|
||||
return layoutGraph(nodes, edges, {
|
||||
nodesep: 58,
|
||||
edgesep: 32,
|
||||
ranksep: 96,
|
||||
marginx: 32,
|
||||
marginy: 32,
|
||||
});
|
||||
},
|
||||
|
||||
/** 把本地 nodes/edges 命令式同步到 Vue Flow store(先节点后边,避免边找不到端点) */
|
||||
|
|
@ -306,15 +339,33 @@ export default {
|
|||
|
||||
/** 重新布局并适应画布 */
|
||||
relayout() {
|
||||
this.nodes = layoutGraph([...this.nodes], this.edges);
|
||||
this.nodes = this.layoutNodes([...this.nodes], this.edges);
|
||||
this.setNodes(this.nodes);
|
||||
this.doFit();
|
||||
this.fitToWidth();
|
||||
},
|
||||
|
||||
doFit() {
|
||||
/** 按宽度保持节点可读,长流程通过滚轮或拖动画布纵向浏览 */
|
||||
fitToWidth(animated = true) {
|
||||
this.$nextTick(() => {
|
||||
try {
|
||||
this.fitView({ padding: 0.25, maxZoom: 1, duration: 300 });
|
||||
const canvas = this.$refs.canvas;
|
||||
if (!canvas || !this.nodes.length) return;
|
||||
const xs = this.nodes.map((n) => n.position.x);
|
||||
const ys = this.nodes.map((n) => n.position.y);
|
||||
const minX = Math.min(...xs);
|
||||
const maxX = Math.max(...xs) + NODE_W;
|
||||
const minY = Math.min(...ys);
|
||||
const paddingX = 48;
|
||||
const paddingTop = 36;
|
||||
const graphWidth = Math.max(maxX - minX, NODE_W);
|
||||
const zoom = Math.min(1, Math.max(0.2, (canvas.clientWidth - paddingX * 2) / graphWidth));
|
||||
const x = (canvas.clientWidth - graphWidth * zoom) / 2 - minX * zoom;
|
||||
const y = paddingTop - minY * zoom;
|
||||
this.setViewport(
|
||||
{ x, y, zoom },
|
||||
{ duration: animated && this.hasFitted ? 200 : 0 }
|
||||
);
|
||||
this.hasFitted = true;
|
||||
} catch (e) {
|
||||
/* noop */
|
||||
}
|
||||
|
|
@ -325,8 +376,7 @@ export default {
|
|||
onNodesInit() {
|
||||
if (this.hasFitted) return;
|
||||
if (this.nodes.length === 0) return;
|
||||
this.hasFitted = true;
|
||||
this.doFit();
|
||||
this.fitToWidth();
|
||||
},
|
||||
|
||||
/* ============ 左侧面板拖拽入画布 ============ */
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<!-- 顶部工具栏:与 xtFlowEditor 视觉一致 -->
|
||||
<div class="flow-toolbar">
|
||||
<div class="flow-toolbar__left">
|
||||
<el-button icon="el-icon-full-screen" size="small" round @click="doFit">适应画布</el-button>
|
||||
<el-button icon="el-icon-full-screen" size="small" round @click="fitToWidth">适应宽度</el-button>
|
||||
</div>
|
||||
<div class="flow-legend">
|
||||
<span class="lg lg--raw">原料</span>
|
||||
|
|
@ -11,11 +11,13 @@
|
|||
<span class="lg lg--final">成品</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flow-canvas">
|
||||
<div ref="canvas" class="flow-canvas">
|
||||
<VueFlow
|
||||
:id="flowId"
|
||||
:min-zoom="0.2"
|
||||
:max-zoom="2"
|
||||
:pan-on-scroll="true"
|
||||
:zoom-on-scroll="false"
|
||||
:nodes-draggable="false"
|
||||
:nodes-connectable="false"
|
||||
:elements-selectable="false"
|
||||
|
|
@ -40,7 +42,7 @@ import { VueFlow, useVueFlow } from "@vue-flow/core";
|
|||
import { Background } from "@vue-flow/background";
|
||||
import { Controls } from "@vue-flow/controls";
|
||||
import MaterialNode from "./flow/MaterialNode.vue";
|
||||
import { layoutGraph } from "./flow/dagreLayout";
|
||||
import { layoutGraph, NODE_W } from "./flow/dagreLayout";
|
||||
import "@vue-flow/core/dist/style.css";
|
||||
import "@vue-flow/core/dist/theme-default.css";
|
||||
import "@vue-flow/controls/dist/style.css";
|
||||
|
|
@ -62,14 +64,17 @@ export default {
|
|||
},
|
||||
setup() {
|
||||
const flowId = "xt-flow-viewer-" + ++viewerSeq;
|
||||
const { fitView, setNodes, setEdges } = useVueFlow(flowId);
|
||||
return { flowId, fitView, setNodes, setEdges };
|
||||
const { setViewport, setNodes, setEdges } = useVueFlow(flowId);
|
||||
return { flowId, setViewport, setNodes, setEdges };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
// 首次 fit 不带动画,避免打开时视图跳动
|
||||
hasFitted: false,
|
||||
rebuildPending: false,
|
||||
renderedNodes: [],
|
||||
resizeObserver: null,
|
||||
resizeTimer: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
|
|
@ -87,6 +92,19 @@ export default {
|
|||
},
|
||||
mounted() {
|
||||
this.rebuild();
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
clearTimeout(this.resizeTimer);
|
||||
this.resizeTimer = setTimeout(() => {
|
||||
if (this.hasFitted) this.fitToWidth(false);
|
||||
}, 80);
|
||||
});
|
||||
this.resizeObserver.observe(this.$refs.canvas);
|
||||
}
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.resizeObserver) this.resizeObserver.disconnect();
|
||||
clearTimeout(this.resizeTimer);
|
||||
},
|
||||
methods: {
|
||||
/** nodes/edges 通常被先后赋值,用微任务合并成一次 rebuild,避免双次 dagre 布局 */
|
||||
|
|
@ -102,11 +120,15 @@ export default {
|
|||
rebuild() {
|
||||
const sourceIds = new Set();
|
||||
const targetIds = new Set();
|
||||
const parallelEdgeCount = {};
|
||||
const rawEdges = (this.edges || [])
|
||||
.filter((e) => e.source != null && e.target != null)
|
||||
.map((e) => {
|
||||
const sid = String(e.source);
|
||||
const tid = String(e.target);
|
||||
const edgeKey = sid + "->" + tid;
|
||||
const edgeIndex = parallelEdgeCount[edgeKey] || 0;
|
||||
parallelEdgeCount[edgeKey] = edgeIndex + 1;
|
||||
sourceIds.add(sid);
|
||||
targetIds.add(tid);
|
||||
return {
|
||||
|
|
@ -114,9 +136,11 @@ export default {
|
|||
source: sid,
|
||||
target: tid,
|
||||
label: e.label || "",
|
||||
type: "smoothstep",
|
||||
// 贝塞尔线会在分叉后立即分离;同一对节点有多道工序时用不同曲率错开。
|
||||
type: "default",
|
||||
pathOptions: { curvature: 0.25 + edgeIndex * 0.12 },
|
||||
markerEnd: "arrowclosed",
|
||||
style: { stroke: "#409eff", strokeWidth: 2 },
|
||||
style: { stroke: "#409eff", strokeWidth: 2.2 },
|
||||
labelStyle: { fill: "#5b6370", fontSize: "12px", fontWeight: 500 },
|
||||
labelBgStyle: { fill: "#ffffff", stroke: "#e4e7ed", strokeWidth: 1 },
|
||||
labelBgPadding: [7, 4],
|
||||
|
|
@ -137,19 +161,47 @@ export default {
|
|||
const nodeIds = new Set(vNodes.map((n) => n.id));
|
||||
const vEdges = rawEdges.filter((e) => nodeIds.has(e.source) && nodeIds.has(e.target));
|
||||
|
||||
this.renderedNodes = layoutGraph(vNodes, vEdges, {
|
||||
rankdir: this.rankdir,
|
||||
nodesep: 58,
|
||||
edgesep: 32,
|
||||
ranksep: 96,
|
||||
marginx: 32,
|
||||
marginy: 32,
|
||||
});
|
||||
this.setEdges([]);
|
||||
this.setNodes(layoutGraph(vNodes, vEdges, { rankdir: this.rankdir }));
|
||||
this.setNodes(this.renderedNodes);
|
||||
this.setEdges(vEdges);
|
||||
// 首次挂载交给 nodes-initialized(尺寸测量完成)再 fit;数据更新时尺寸已就绪,直接 fit
|
||||
if (this.hasFitted) this.doFit();
|
||||
// 首次挂载交给 nodes-initialized(尺寸测量完成)再定位;数据更新时直接重新适配宽度
|
||||
if (this.hasFitted) this.fitToWidth();
|
||||
},
|
||||
onNodesInit() {
|
||||
this.doFit();
|
||||
this.fitToWidth();
|
||||
},
|
||||
doFit() {
|
||||
/**
|
||||
* 只按宽度缩放,长流程不再为了塞进高度而缩成缩略图。
|
||||
* 图从画布顶部开始展示,用户可拖动画布继续向下查看。
|
||||
*/
|
||||
fitToWidth(animated = true) {
|
||||
this.$nextTick(() => {
|
||||
try {
|
||||
this.fitView({ padding: 0.2, maxZoom: 1, duration: this.hasFitted ? 200 : 0 });
|
||||
const canvas = this.$refs.canvas;
|
||||
if (!canvas || !this.renderedNodes.length) return;
|
||||
const xs = this.renderedNodes.map((n) => n.position.x);
|
||||
const ys = this.renderedNodes.map((n) => n.position.y);
|
||||
const minX = Math.min(...xs);
|
||||
const maxX = Math.max(...xs) + NODE_W;
|
||||
const minY = Math.min(...ys);
|
||||
const paddingX = 48;
|
||||
const paddingTop = 36;
|
||||
const graphWidth = Math.max(maxX - minX, NODE_W);
|
||||
const zoom = Math.min(1, Math.max(0.2, (canvas.clientWidth - paddingX * 2) / graphWidth));
|
||||
const x = (canvas.clientWidth - graphWidth * zoom) / 2 - minX * zoom;
|
||||
const y = paddingTop - minY * zoom;
|
||||
this.setViewport(
|
||||
{ x, y, zoom },
|
||||
{ duration: animated && this.hasFitted ? 200 : 0 }
|
||||
);
|
||||
this.hasFitted = true;
|
||||
} catch (e) {
|
||||
/* noop */
|
||||
|
|
|
|||
Loading…
Reference in New Issue