feat: 拓扑编辑代码优化

This commit is contained in:
TsMask
2023-12-28 15:20:04 +08:00
parent fb4babf09a
commit 5e463a6ce8
5 changed files with 3347 additions and 2699 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,151 @@
import { message, Form } from 'ant-design-vue/lib';
import { reactive, watch } from 'vue';
import { graphG6 } from './useGraph';
/**图边内置边类型 */
export const edgeTypeOptions = [
{
value: 'line',
label: '直线,连接两个节点的直线',
},
{
value: 'polyline',
label: '折线,多段线段构成的折线,连接两个端点',
},
{
value: 'arc',
label: '圆弧线,连接两个节点的一段圆弧',
},
{
value: 'quadratic',
label: '二阶贝塞尔曲线,只有一个控制点的曲线',
},
{
value: 'cubic',
label: '三阶贝塞尔曲线,有两个控制点的曲线',
},
{
value: 'cubic-vertical',
label: '垂直方向的三阶贝塞尔曲线',
},
{
value: 'cubic-horizontal',
label: '水平方向的三阶贝塞尔曲线',
},
{
value: 'loop',
label: '自环',
},
];
/**图边标签文本位置 */
export const edgePositionOptions = [
{
value: 'start',
label: '开头',
},
{
value: 'middle',
label: '中间',
},
{
value: 'end',
label: '末尾',
},
];
/**边信息状态类型 */
type EdgeStateType = {
/**图边原始数据 */
origin: Record<string, any>;
/**图边表单数据 */
form: Record<string, any>;
};
/**边信息状态 */
export let edgeState: EdgeStateType = reactive({
origin: {},
form: {
id: '',
source: '',
target: '',
type: 'polyline',
style: {
offset: 20,
radius: 2,
stroke: '#ffffff',
lineWidth: 1,
cursor: 'pointer',
},
label: '',
labelCfg: {
refX: 0,
refY: 0,
position: 'middle',
autoRotate: false,
style: {
fill: '#ffffff',
fontSize: 12,
fontWeight: 500,
},
},
},
});
/**图边对话框内表单属性和校验规则 */
export const edgeStateForm = Form.useForm(
edgeState.form,
reactive({
id: [{ required: true, message: '边唯一 ID' }],
source: [{ required: true, message: '起始点 id' }],
target: [{ required: true, message: '结束点 id' }],
type: [{ required: true, message: 'line' }],
})
);
/**图边编辑监听更新视图 */
watch(edgeState.form, edge => {
const info = JSON.parse(JSON.stringify(edge));
const edgeId = info.id;
if (edgeId) {
graphG6.value.clearItemStates(edgeId, 'selected');
graphG6.value.updateItem(edgeId, info);
}
});
/**图边新增或更新 */
export function handleOkEdge() {
const edge = JSON.parse(JSON.stringify(edgeState.form));
if (!edge.id) {
message.warn({
content: `边元素ID错误`,
duration: 2,
});
return false;
}
// graphG6.value.removeItem(edge.id);
// edge.id = `${edge.source}~${Date.now()}~${edge.target}`;
// graphG6.value.addItem('edge', edge);
const item = graphG6.value.findById(edge.id);
if (item) {
graphG6.value.updateItem(item, edge);
} else {
edge.id = `${edge.source}~${Date.now()}~${edge.target}`;
graphG6.value.addItem('edge', edge);
}
edgeStateForm.resetFields();
edgeState.origin = {};
return true;
}
/**图边取消还原 */
export function handleCancelEdge() {
const origin = JSON.parse(JSON.stringify(edgeState.origin));
if (origin.id) {
graphG6.value.updateItem(origin.id, origin);
// graphG6.value.removeItem(edgeOrigin.id);
// graphG6.value.addItem('edge', edgeOrigin);
edgeStateForm.resetFields();
edgeState.origin = {};
}
}

View File

@@ -0,0 +1,422 @@
import {
Graph,
GraphData,
ICanvas,
IShapeBase,
Item,
Menu,
Tooltip,
} from '@antv/g6';
import { ref } from 'vue';
/**图实例对象 */
export const graphG6 = ref<any>(null);
/**图事件变更 */
export const graphEvent = ref<{
type: string;
target: HTMLElement | (IShapeBase & ICanvas);
item: Item | null;
}>();
/**图画布右击菜单 */
const graphCanvasMenu = new Menu({
offsetX: 6,
offseY: 10,
itemTypes: ['canvas'],
getContent(evt) {
return `
<div
style="
display: flex;
flex-direction: column;
width: 140px;
"
>
<div id="show" style="cursor: pointer; margin-bottom: 2px">
1. 显示所有隐藏项
</div>
</div>`;
},
handleMenuClick(target, item) {
console.log(target, item);
const targetId = target.id;
switch (targetId) {
case 'show':
// 显示节点
graphG6.value.getNodes().forEach((node: any) => {
if (!node.isVisible()) {
graphG6.value.showItem(node);
graphG6.value.refreshItem(node);
}
});
// 显示边
graphG6.value.getEdges().forEach((edge: any) => {
if (!edge.isVisible()) {
graphG6.value.showItem(edge);
graphG6.value.refreshItem(edge);
}
});
break;
}
},
});
/**图节点右击菜单 */
const graphNodeMenu = new Menu({
offsetX: 6,
offseY: 10,
itemTypes: ['node'],
getContent(evt) {
console.log(evt);
return `
<div
style="
display: flex;
flex-direction: column;
width: 140px;
background: #e6f7ff;
"
>
<div id="edit" style="cursor: pointer; margin-bottom: 2px">
1. 编辑
</div>
<div id="hide" style="cursor: pointer; margin-bottom: 2px">
2. 隐藏
</div>
</div>
`;
},
handleMenuClick(target, item) {
console.log(target, item);
const targetId = target.id;
switch (targetId) {
case 'edit':
graphEvent.value = { type: `nodeMenu-${targetId}`, target, item };
break;
case 'hide':
graphG6.value.hideItem(item);
break;
}
},
});
/**图节点展示 */
const graphNodeTooltip = new Tooltip({
offsetX: 10,
offsetY: 20,
getContent(e: any) {
const outDiv = document.createElement('div');
outDiv.style.width = '180px';
outDiv.innerHTML = `
<h4>自定义tooltip</h4>
<ul>
<li>Label: ${e.item.getModel().label || e.item.getModel().id}</li>
</ul>`;
return outDiv;
},
itemTypes: ['node'],
});
/**图边右击菜单 */
const graphEdgeMenu = new Menu({
offsetX: 6,
offseY: 10,
itemTypes: ['edge'],
getContent(evt) {
console.log(evt);
return `
<div
style="
display: flex;
flex-direction: column;
width: 140px;
background: #e6f7ff;
"
>
<div id="edit" style="cursor: pointer; margin-bottom: 2px">
1. 编辑
</div>
<div id="hide" style="cursor: pointer; margin-bottom: 2px">
2. 隐藏
</div>
</div>
`;
},
handleMenuClick(target, item) {
console.log(target, item);
const targetId = target.id;
switch (targetId) {
case 'edit':
graphEvent.value = { type: `edgeMenu-${targetId}`, target, item };
break;
case 'hide':
graphG6.value.hideItem(item);
break;
}
},
});
/**图边展示 */
const graphEdgeTooltip = new Tooltip({
offsetX: 10,
offsetY: 20,
getContent(e: any) {
const outDiv = document.createElement('div');
outDiv.style.width = '180px';
outDiv.innerHTML = `
<h4>graphEdgeTooltip</h4>
<ul>
<li>Label: ${e.item.getModel().label || e.item.getModel().id}</li>
</ul>`;
return outDiv;
},
itemTypes: ['edge'],
});
/**图绑定事件 */
function fnGraphEvent(graph: Graph) {
// 调用 graph.add / graph.addItem 方法之后触发
graph.on('afteradditem', evt => {
fnSelectSourceTargetOptionsData();
});
// 鼠标进入节点事件
graph.on('edge:mouseenter', (ev: any) => {
// 获得鼠标当前目标边
const edge = ev.item;
// 该边的起始点
const source = edge.getSource();
// 该边的结束点
const target = edge.getTarget();
// 先将边提前,再将端点提前。这样该边两个端点还是在该边上层,较符合常规。
// edge.toFront();
// source.toFront();
// target.toFront();
});
graph.on('edge:mouseleave', (ev: any) => {
// 获得图上所有边实例
const edges = graph.getEdges();
// 遍历边,将所有边的层级放置在后方,以恢复原样
// edges.forEach(edge => {
// edge.toBack();
// });
});
graph.on('node:mouseenter', evt => {
// 获得鼠标当前目标节点
const node = evt.item;
// 获取该节点的所有相关边
const edges = node && graph.getEdges();
// 遍历相关边,将所有相关边提前,再将相关边的两个端点提前,以保证相关边的端点在边的上方常规效果
// edges.forEach((edge: any) => {
// edge.toFront();
// edge.getSource().toFront();
// edge.getTarget().toFront();
// });
// graphEvent.value = {
// type: 'node:mouseenter',
// target: evt.target,
// item: evt.item,
// };
});
graph.on('node:mouseleave', (ev: any) => {
// 获得图上所有边实例
const edges = graph.getEdges();
// 遍历边,将所有边的层级放置在后方,以恢复原样
// edges.forEach(edge => {
// edge.toBack();
// });
});
}
/**图元素选择开始结束点 */
export const selectSourceTargetOptions = ref<Record<string, any>[]>([]);
/**
* 图元素选择开始结束点数据获取
*/
function fnSelectSourceTargetOptionsData() {
// 节点
graphG6.value.getNodes().forEach((node: any) => {
const info = JSON.parse(JSON.stringify(node.getModel()));
selectSourceTargetOptions.value.push({
value: info.id,
label: info.label,
info,
});
});
// 框
graphG6.value.getCombos().forEach((combo1: any) => {
const info = JSON.parse(JSON.stringify(combo1.getModel()));
selectSourceTargetOptions.value.push({
value: info.id,
label: info.label,
info,
});
});
}
/**图数据渲染 */
export function handleRanderGraph(container: HTMLElement, data: GraphData) {
if (!container) return;
const { clientHeight, clientWidth } = container;
const graph = new Graph({
container: container,
width: clientWidth,
height: clientHeight,
animate: true,
fitCenter: true,
modes: {
// default: [
// // 允许拖拽画布、放缩画布、拖拽节点
// 'drag-canvas',
// 'zoom-canvas',
// 'drag-node',
// ],
default: [
{
type: 'click-select',
selectEdge: true,
},
'drag-combo',
{
type: 'drag-node',
onlyChangeComboSize: true,
},
'drag-canvas',
'zoom-canvas',
'collapse-expand-combo',
],
edit: [
{
type: 'click-select',
selectEdge: true,
},
{
type: 'drag-node',
shouldEnd: (e: any) => {
return true;
},
},
{ type: 'drag-combo' },
'drag-canvas',
'zoom-canvas',
{ type: 'create-edge', key: 'alt' },
],
},
groupByTypes: false,
// layout: {
// type: 'dagre',
// sortByCombo: false,
// ranksep: 10,
// nodesep: 10,
// },
// 全局节点 矩形
defaultNode: {
type: 'rect',
size: [80, 40],
style: {
radius: 8,
// fill: '#ffffff',
stroke: '#ffffff',
lineWidth: 1,
cursor: 'pointer',
},
labelCfg: {
position: 'center',
offset: 0,
style: {
fill: '#000000',
fontSize: 12,
fontWeight: 500,
},
},
icon: {
show: false,
img: '/svg/service.svg',
width: 25,
height: 25,
offset: 20, // triangle 特有
},
direction: 'up', // triangle 三角形的方向
},
// 全局边 三次贝塞尔曲线
defaultEdge: {
type: 'polyline',
style: {
offset: 20, // 拐弯处距离节点最小距离
radius: 2, // 拐弯处的圆角弧度,若不设置则为直角
stroke: '#ffffff',
lineWidth: 1,
cursor: 'pointer',
},
labelCfg: {
refX: 0,
refY: 0,
position: 'middle',
autoRotate: false,
style: {
fill: '#ffffff',
fontSize: 12,
fontWeight: 500,
},
},
},
// defaultEdge: {
// type: 'line',
// },
// 全局框节点 矩形
defaultCombo: {
type: 'rect', // Combo 类型
size: [40, 40],
style: {
fillOpacity: 0.1,
},
},
plugins: [
graphCanvasMenu,
graphNodeMenu,
graphNodeTooltip,
graphEdgeMenu,
graphEdgeTooltip,
],
});
graph.data(data);
graph.render();
// 图绑定事件
fnGraphEvent(graph);
graphG6.value = graph;
// 图元素选择开始结束点数据
fnSelectSourceTargetOptionsData();
return graph;
}
/**图模式选择项 */
export const graphModeOptions = [
{
value: 'default',
label: '默认',
},
{
value: 'edit',
label: '编辑',
},
];
/**图模式选择项 */
export const graphMode = ref<string>('default');
/**图模式改变 default | edit */
export function handleChangeMode(value: any) {
console.log(value, JSON.parse(JSON.stringify(graphG6.value.save())));
graphG6.value.setMode(value);
graphMode.value = graphG6.value.getCurrentMode();
}

View File

@@ -0,0 +1,294 @@
import { message, Form } from 'ant-design-vue/lib';
import { reactive, watch } from 'vue';
import { graphG6 } from './useGraph';
/**图节点内置边类型 */
export const nodeTypeOptions = [
{
value: 'circle',
label: '圆形',
},
{
value: 'rect',
label: '矩形',
},
{
value: 'ellipse',
label: '椭圆',
},
{
value: 'diamond',
label: '菱形',
},
{
value: 'triangle',
label: '三角形',
},
{
value: 'star',
label: '星形',
},
{
value: 'image',
label: '图片',
},
{
value: 'donut',
label: '面包圈',
},
];
/**图节点标签文本位置 */
export const nodePositionOptions = [
{
value: 'top',
label: '上',
},
{
value: 'left',
label: '左',
},
{
value: 'right',
label: '右',
},
{
value: 'bottom',
label: '下',
},
{
value: 'center',
label: '居中',
},
];
/**图节点三角形方向 */
export const nodeDirectionOptions = [
{ value: 'up', label: '向上' },
{ value: 'down', label: '向下' },
{ value: 'left', label: '向左' },
{ value: 'right', label: '向右' },
];
/**图节点图片裁剪的形状 */
export const nodeImageClipCfgOptions = [
{ value: 'circle', label: '圆形' },
{ value: 'rect', label: '矩形' },
{ value: 'ellipse', label: '椭圆' },
];
/**图节点图片来源 */
export const nodeImageOptions = [
{ value: '/svg/base.svg', label: '基站' },
{ value: '/svg/cloud.svg', label: '云' },
{ value: '/svg/service.svg', label: '服务器' },
{ value: '/svg/service_db.svg', label: '数据服务器' },
];
/**图节点信息状态类型 */
type NodeStateType = {
/**图节点原始数据 */
origin: Record<string, any>;
/**图节点表单数据 */
form: Record<string, any>;
};
/**图节点信息状态 */
export let nodeState: NodeStateType = reactive({
origin: {},
form: {
id: '',
x: 0,
y: 0,
type: 'circle',
size: [0],
anchorPoints: false,
style: {
fill: '#ffffff',
stroke: '#ffffff',
lineWidth: 1,
},
label: '',
labelCfg: {
position: 'center',
offset: 0,
style: {
fill: '#000000',
fontSize: 12,
fontWeight: 500,
},
},
},
});
/**图节点对话框内表单属性和校验规则 */
export const nodeStateForm = Form.useForm(
nodeState.form,
reactive({
id: [{ required: true, message: '节点唯一 ID' }],
type: [{ required: true, message: 'line' }],
})
);
/**图节点编辑监听更新视图 */
watch(nodeState.form, node => {
const info = JSON.parse(JSON.stringify(node));
const nodeId = info.id;
if (nodeId) {
// 图片类型需要移除style属性避免填充
if (info.type === 'image') {
Reflect.deleteProperty(info, 'style');
}
graphG6.value.clearItemStates(nodeId, 'selected');
graphG6.value.updateItem(nodeId, info);
// 三角和图片的样式变更需要重绘才生效
if (info.type === 'triangle' || info.type === 'image') {
graphG6.value.read(graphG6.value.save());
}
}
});
/**图节点类型输入限制 */
export function handleTypeChange(type: any) {
// 设置图标属性
if (['circle', 'ellipse', 'diamond', 'star', 'donut'].includes(type)) {
const origin = nodeState.origin;
if (origin.icon) {
nodeState.form = Object.assign(nodeState.form, {
icon: origin.icon,
});
} else {
nodeState.form = Object.assign(nodeState.form, {
icon: {
show: false,
img: '',
width: 25,
height: 25,
},
});
}
} else if (type === 'triangle') {
// 三角
const origin = nodeState.origin;
if (origin.icon) {
nodeState.form = Object.assign(nodeState.form, {
direction: origin.direction || 'up', // triangle 三角形的方向
icon: Object.assign({ offset: 20 }, origin.icon),
});
} else {
nodeState.form = Object.assign(nodeState.form, {
direction: 'up', // triangle 三角形的方向
icon: {
show: false,
img: '/svg/service.svg',
width: 25,
height: 25,
offset: 20, // triangle 特有
},
});
}
}
// 设置图片属性
if (type === 'image') {
const origin = nodeState.origin;
if (origin.img) {
nodeState.form = Object.assign(nodeState.form, {
img: origin.img,
clipCfg: origin.clipCfg,
});
} else {
nodeState.form = Object.assign(nodeState.form, {
img: '/svg/service.svg',
clipCfg: {
show: false,
width: 0,
height: 0,
type: 'circle',
},
});
}
Reflect.deleteProperty(nodeState.form, 'style');
} else {
// 当切换非图片时补充style属性
if (!Reflect.has(nodeState.form, 'style')) {
nodeState.form = Object.assign(nodeState.form, {
style: {
fill: '#ffffff',
stroke: '#ffffff',
lineWidth: 1,
},
});
}
}
}
/**图节点大小输入限制 */
export function handleSizeChange(value: any) {
// 处理格式
let intArr: number[] = [];
for (const v of value) {
const intV = parseInt(v);
if (!isNaN(intV)) {
intArr.push(intV);
}
}
// 节点类型限制size
const nodeType = nodeState.form.type;
switch (nodeType) {
case 'circle':
case 'star':
case 'donut':
intArr = intArr.slice(0, 1);
break;
case 'rect':
case 'ellipse':
case 'diamond':
case 'triangle':
case 'image':
intArr = intArr.slice(0, 2);
break;
}
nodeState.form.size = intArr;
}
/**图节点新增或更新 */
export function handleOkNode() {
const node = JSON.parse(JSON.stringify(nodeState.form));
if (!node.id) {
message.warn({
content: `节点元素ID错误`,
duration: 2,
});
return false;
}
// graphG6.value.removeItem(node.id);
// graphG6.value.addItem('node', node);
const item = graphG6.value.findById(node.id);
if (item) {
graphG6.value.updateItem(item, node);
} else {
graphG6.value.addItem('node', node);
}
// 三角和图片的样式变更需要重绘才生效
if (node.type === 'triangle' || node.type === 'image') {
graphG6.value.read(graphG6.value.save());
}
nodeStateForm.resetFields();
nodeState.origin = {};
return true;
}
/**图节点取消还原 */
export function handleCancelNode() {
const origin = JSON.parse(JSON.stringify(nodeState.origin));
if (origin.id) {
graphG6.value.updateItem(origin.id, origin);
// 三角和图片的样式变更需要重绘才生效
if (origin.type === 'triangle' || origin.type === 'image') {
graphG6.value.read(graphG6.value.save());
}
console.log(JSON.parse(JSON.stringify(nodeState.form)));
nodeStateForm.resetFields();
nodeState.origin = {};
}
}

File diff suppressed because it is too large Load Diff