
如上图,要用 G6 做出这个关系图,现在需要满足三个要求:
7 auth time 要连接 9 time_type我查了一下 文档,这种情况自定义节点最好应该是 新增一种 shape dom, 然后自己拼 html 形成一个列表,并加上 css 自定义样式。于是我结合 treeGraph 得到下面这种形状

这满足了要求 1, 但是 2 和 3 无法满足。
对于要求2, 自定义 html 的节点如何自定义点击事件具体到列表的每一项?
对于要求 3,我看了文档可以自定义锚点位置,
但是我这个节点是 html 制成的,如何指定连接到列表某一项的位置?
new Graph的时候指定 renderer: 'svg' ,切换成使用SVG渲染呢?
使用使用Canvas渲染,可以考虑以下做法:
1、定义节点时候,每个节点中的每一行是一个rect;
2、点击获取节点部分,可参考这里;
3、指定anchorPoints,用于指定连接位置。
@baizn 非常感谢~ 我就是用 svg 的,那个列表我是拼 html 的,用 rect 一个个拼也太麻烦了。。。
@baizn 你给的这个例子是用 canvas 的, 用 svg 渲染如何响应节点区域事件呢?
我完成了

另外吐槽一下, 3.0.4 API 不兼容 3.0.2, API 有破坏式更新时你们应该升大版本号
我完成了
有时间的话可以作为一个demo提交一个pr,有类似需求的同学就可以直接参考了。
好的
@dd1994 请教一下,你是如何实现的,自定义html节点,边无法显示,能给个demo参考下吗,非常感谢!
@dd1994 大佬 你好 你最后完成的作品,可以提供一个demo参考下吗?非常感谢。
我封装了一个组件
<template>
<div class="table-relation-graph">
<div ref="mountNode"></div>
<m:jiangdi.conextmenu ref="menu">
<template slot-scope="child">
<ul v-if="getMenuData(child.data).info">
<li
:key="index"
v-for="(item, index) in getMenuData(child.data).info.menu"
@click="menuClick(getMenuData(child.data).info, item.type)"
>
{{ item.name }}
</li>
</ul>
</template>
</m:jiangdi.conextmenu>
</div>
</template>
<script>
import G6 from "@antv/g6";
import Hierarchy from "@antv/hierarchy";
// import contextMenu from "./menu.vue";
const COLLAPSE_ICON = function COLLAPSE_ICON(x, y, r) {
return [
["M", x, y],
["a", r, r, 0, 1, 0, r * 2, 0],
["a", r, r, 0, 1, 0, -r * 2, 0],
["M", x + 2, y],
["L", x + 2 * r - 2, y]
];
};
const EXPAND_ICON = function EXPAND_ICON(x, y, r) {
return [
["M", x, y],
["a", r, r, 0, 1, 0, r * 2, 0],
["a", r, r, 0, 1, 0, -r * 2, 0],
["M", x + 2, y],
["L", x + 2 * r - 2, y],
["M", x + r, y - r + 2],
["L", x + r, y + r - 2]
];
};
const textCut = (text, n) => {
let result = text.slice(0, n);
if (text.length > n) {
result += "...";
}
return result;
};
export default {
props: {
nodeData: {
type: Object,
default: () => {}
},
edgeData: {
type: Array,
default: () => []
},
width: {
type: Number
},
height: {
type: Number
}
},
data() {
return {
graph: {}
};
},
mounted() {
this.registerNode();
this.registerBehavior();
this.initGraph();
},
methods: {
registerNode() {
G6.registerNode("list-node", {
draw: (cfg, group) => {
let tableName = cfg.data.name;
let tableId = cfg.data.id;
let fields = cfg.data.fields;
const rectWidth = 220;
const rectHeight = 30;
if (!tableName) {
throw new Error("name 字段为必填项");
}
let fieldNameFillColor = "rgb(248, 248, 250)";
const tableNameFillColor = "rgb(84,96,115)";
let fontColor = "#707070";
group.addShape("rect", {
attrs: {
x: 0,
y: 0,
tableId: tableId,
width: rectWidth,
height: rectHeight,
radius: [2, 2],
fill: tableNameFillColor,
type: "tableName"
}
});
group.addShape("text", {
attrs: {
x: 10,
y: rectHeight - 4,
tableId: tableId,
fontFamily: "PingFang SC",
text: textCut(tableName, 20),
fontSize: 18,
fill: "#fff",
type: "tableName"
}
});
group.addShape("marker", {
attrs: {
x: rectWidth - 12,
y: rectHeight / 2,
r: 7,
symbol: "triangle-down",
fill: "#fff",
tableId: cfg.data.id,
type: "field-expand-icon"
},
className: `field-expand`
});
if (fields && fields.length) {
fields.forEach((item, index) => {
const hasChildren = this.edgeData.find(i => {
return i.source.id === tableId && i.source.field === item.id;
});
group.addShape("rect", {
attrs: {
x: 0,
y: (index + 1) * rectHeight,
width: rectWidth,
height: rectHeight,
stroke: "rgb(240, 240, 241)",
radius: [2, 2],
fill: fieldNameFillColor,
tableId: tableId,
fieldId: item.id,
fieldIndex: index,
type: "fieldName"
}
});
const hasType4 = item.info.menu.find(i => i.type === 4)
group.addShape("text", {
attrs: {
x: 10,
y: (index + 2) * rectHeight - 4,
fontFamily: "PingFang SC",
text: textCut(item.name, 15),
fontSize: 18,
fill: hasType4 ? "rgb(102, 177, 255)" : fontColor,
tableId: tableId,
fieldId: item.id,
fieldIndex: index,
type: "fieldName"
}
});
group.addShape("image", {
attrs: {
x: rectWidth - 45,
y: rectHeight / 2 + (index + 1) * rectHeight - 13,
width: 24,
height: 24,
tableId: tableId,
fieldId: item.id,
type: "menu",
img: "https://si.geilicdn.com/img-0a220000016c85e2ef940a21924a-unadjust_200_200.png"
}
});
if (!hasChildren) {
return;
}
group.addShape("marker", {
attrs: {
x: rectWidth - 12,
y: rectHeight / 2 + (index + 1) * rectHeight,
r: 6,
symbol: COLLAPSE_ICON,
stroke: "rgb(153,173,206)",
lineWidth: 2,
tableId: cfg.data.id,
fieldId: item.id,
fieldIndex: index,
type: "expand-icon"
},
className: `expand-icon-${index}`
});
});
}
return group;
},
getAnchorPoints(cfg) {
const fields = cfg.data.fields;
const singleLength = 1 / (fields.length + 1);
return [
[1, singleLength / 2],
...fields.map((item, index) => {
return [1, singleLength / 2 + singleLength * (index + 1)];
}),
[0, singleLength / 2],
...fields.map((item, index) => {
return [0, singleLength / 2 + singleLength * (index + 1)];
})
];
}
});
},
registerBehavior() {},
initGraph() {
if (this.graph && this.graph.destroy) {
this.graph.destroy();
}
// 没有边的数据,或节点的数据则中断
if (!this.nodeData || !this.nodeData.id) {
return;
}
if (!this.edgeData) {
return;
}
const noChildren = (!this.edgeData) || (this.edgeData.length < 2)
this.graph = new G6.TreeGraph({
container: this.$refs.mountNode,
width: noChildren ? 500 : this.width,
height: this.height,
modes: {
default: []
},
defaultEdge: {
shape: "cubic-horizontal",
style: {
stroke: "rgb(181,181,193)",
lineWidth: 2
}
},
defaultNode: {
shape: "list-node"
},
nodeStyle: {
default: {}
},
layout: function layout(data) {
return Hierarchy.mindmap(data, {
direction: "H",
getHeight() {
return 150;
},
getWidth() {
return 150;
},
getVGap() {
return 10;
},
getHGap() {
return 100;
},
getSide() {
return "right";
}
});
}
});
this.graph.data(this.nodeData);
this.graph.render();
this.setEdgesAnchor();
this.setEvents();
this.graph.refresh();
this.graph.fitView();
},
adjustTargetAnchorWhenFieldCollapse(node) {
const edges = node.getInEdges();
edges.forEach(i => {
i.get("model").targetAnchor = node.getModel().data.fields.length + 1;
});
},
setEdgesAnchor() {
this.graph.getEdges().forEach(edge => {
const sourceItem = edge.getSource();
const targetItem = edge.getTarget();
const thisEdge = this.edgeData.find(item => {
return item.source.id === edge.getModel().source && item.target.id === edge.getModel().target;
});
if (thisEdge.related === false) {
edge.get("model").style = {
lineDash: [10, 10],
stroke: "rgb(181,181,193)",
lineWidth: 2
};
}
const source = thisEdge.source;
const target = thisEdge.target;
let sourceAnchorIndex = 0;
G6.Util.traverseTree(this.nodeData, item => {
if (source.id === item.id) {
sourceAnchorIndex = item.fields.findIndex(i => i.id === source.field) + 1;
}
});
let targetAnchorIndex = 0;
G6.Util.traverseTree(this.nodeData, item => {
if (target.id === item.id) {
targetAnchorIndex = item.fields.length + 2 + item.fields.findIndex(i => i.id === target.field);
}
});
edge.get("model").sourceAnchor = sourceAnchorIndex;
edge.get("model").targetAnchor = targetAnchorIndex;
});
},
setEvents() {
this.graph.on("node:click", ev => {
let _this = this;
const target = ev.target;
function collapse(item, fieldId = null) {
let children = item.get("model").children;
if (fieldId) {
_this.graph.setItemState(item, String(fieldId), true);
let edge = _this.edgeData.find(i => {
return i.source.id === item.get("model").id && i.source.field === fieldId;
});
if (!edge) {
return;
}
children = item.get("model").children.filter(i => {
return i.id === edge.target.id;
});
}
G6.Util.each(children, child => {
const node = _this.graph.findById(child.id);
collapse(node);
_this.graph.hideItem(node);
});
}
function expand(item, fieldId) {
let children = item.get("model").children;
if (fieldId) {
_this.graph.setItemState(item, String(fieldId), false);
let edge = _this.edgeData.find(i => {
return i.source.id === item.get("model").id && i.source.field === fieldId;
});
if (!edge) {
return;
}
children = item.get("model").children.filter(i => {
return i.id === edge.target.id;
});
}
_this.graph.showItem(item);
G6.Util.each(children, child => {
const node = _this.graph.findById(child.id);
expand(node);
});
}
const triggerExpandChildren =
(target.attr && target.attr("type") === "expand-icon") || target.attr("type") === "fieldName";
const triggerExpandFields =
(target.attr && target.attr("type") === "field-expand-icon") || target.attr("type") === "tableName";
const triggerShowMenu = target.attr && target.attr("type") === "menu";
if (triggerExpandChildren) {
const fieldId = target.attr("fieldId");
const fieldIndex = target.attr("fieldIndex");
const fieldIdStr = String(fieldId);
const icon = ev.item.get("group").findByClassName(`expand-icon-${fieldIndex}`);
const collapsed = !ev.item.hasState(fieldIdStr);
if (collapsed) {
icon && icon.attr("symbol", EXPAND_ICON);
collapse(ev.item, fieldId);
} else {
icon && icon.attr("symbol", COLLAPSE_ICON);
expand(ev.item, fieldId);
}
} else if (triggerExpandFields) {
let item = ev.item;
const tableCollapsed = !item.hasState("table-collapsed");
this.graph.setItemState(item, "table-collapsed", tableCollapsed);
const group = item.get("group");
const icon = group.findByClassName(`field-expand`);
const hideFields = () => {
group.get("children").forEach(i => {
const type = i.attr("type");
if (type === "tableName") {
// do nothing
} else if (type === "field-expand-icon") {
// do nothing
} else {
i.hide();
}
});
};
const showFields = () => {
group.get("children").forEach(i => {
const type = i.attr("type");
if (type === "tableName") {
// do nothing
} else if (type === "field-expand-icon") {
// do nothing
} else {
i.show();
}
});
};
if (tableCollapsed) {
icon && icon.attr("symbol", "triangle");
collapse(item);
hideFields();
// hack: G6 的 bug, 当没有子节点时无法隐藏,要手动 hide 一下再 show 一下。。。
this.graph.hideItem(item);
this.graph.showItem(item);
this.adjustTargetAnchorWhenFieldCollapse(item);
} else {
icon && icon.attr("symbol", "triangle-down");
expand(item);
showFields();
this.setEdgesAnchor();
}
this.graph.refreshPositions();
} else if (triggerShowMenu) {
if (this.$refs.menu.showContextMenu) {
this.closeMenu();
} else {
this.$refs.menu.open(ev.event, {
tableId: target.attr("tableId"),
fieldId: target.attr("fieldId")
});
}
}
});
this.graph.on("canvas:click", () => {
this.closeMenu();
});
},
closeMenu() {
this.$refs.menu.close();
},
getMenuData({ tableId, fieldId }) {
let data = {};
if (!this.nodeData || !this.nodeData.id) {
return data;
}
G6.Util.traverseTree(this.nodeData, item => {
if (item.id === tableId) {
data = item.fields.find(i => i.id === fieldId);
}
});
return data;
},
menuClick(info, type) {
this.closeMenu();
this.$emit("menuClick", {
info,
type
});
}
},
components: {
// contextMenu
},
watch: {
nodeData: {
handler: function() {
this.initGraph();
},
deep: true
},
edgeData: {
handler: function() {
this.initGraph();
},
deep: true
}
}
};
</script>
<style lang="less">
.table-relation-graph {
height: 100%;
width: 100%;
}
</style>
使用方式如下:
<template>
<div>
<tableRelationGraph
:nodeData="nodeData"
:edgeData="edgeData"
@menuClick="menuClick"
:width="width"
:height="height"
></tableRelationGraph>
</div>
</template>
<script>
import tableRelationGraph from "./index.vue";
let nodeData = {
name: "dw.dwd_trd_sub_ord",
id: "57",
type: false,
root: true,
fields: [
{
name: "seller_id",
id: 215,
info: {
route: "",
tableId: 57,
modelId: 28,
menu: [
{
name: "新建原子指标",
type: 0
},
{
name: "新建统计粒度",
type: 1
},
{
name: "新建业务限定",
type: 2
},
{
name: "新建时间周期",
type: 3
}
]
}
},
{
name: "buyer_id",
id: 216,
info: {
route: "",
tableId: 57,
modelId: 28,
menu: [
{
name: "新建原子指标",
type: 0
},
{
name: "新建统计粒度",
type: 1
},
{
name: "新建业务限定",
type: 2
},
{
name: "新建时间周期",
type: 3
}
]
}
},
{
name: "finish_time",
id: 217,
info: {
route: "",
tableId: 57,
modelId: 28,
menu: [
{
name: "新建原子指标",
type: 0
},
{
name: "新建统计粒度",
type: 1
},
{
name: "新建业务限定",
type: 2
},
{
name: "新建时间周期",
type: 3
}
]
}
},
{
name: "refund_time",
id: 218,
info: {
route: "",
tableId: 57,
modelId: 28,
menu: [
{
name: "新建原子指标",
type: 0
},
{
name: "新建统计粒度",
type: 1
},
{
name: "新建业务限定",
type: 2
},
{
name: "新建时间周期",
type: 3
}
]
}
}
],
children: [
{
name: "dw.dim_buyer",
id: "52_17",
type: false,
fields: [
{
name: "buyer_id",
id: 205,
info: {
route: "17",
tableId: 52,
modelId: 28,
menu: [
{
name: "新建原子指标",
type: 0
},
{
name: "新建统计粒度",
type: 1
},
{
name: "新建业务限定",
type: 2
},
{
name: "新建时间周期",
type: 3
}
]
}
},
{
name: "card_type",
id: 206,
info: {
route: "17",
tableId: 52,
modelId: 28,
menu: [
{
name: "新建原子指标",
type: 0
},
{
name: "新建统计粒度",
type: 1
},
{
name: "新建业务限定",
type: 2
},
{
name: "新建时间周期",
type: 3
}
]
}
}
],
children: [
{
name: "dw.dim_buyer_card",
id: "54_17,15",
type: false,
fields: [
{
name: "card_type",
id: 209,
info: {
route: "17,15",
tableId: 54,
modelId: 28,
menu: [
{
name: "新建原子指标",
type: 0
},
{
name: "新建统计粒度",
type: 1
},
{
name: "新建业务限定",
type: 2
},
{
name: "新建时间周期",
type: 3
}
]
}
},
{
name: "card_desc",
id: 210,
info: {
route: "17,15",
tableId: 54,
modelId: 28,
menu: [
{
name: "新建原子指标",
type: 0
},
{
name: "新建统计粒度",
type: 1
},
{
name: "新建业务限定",
type: 2
},
{
name: "新建时间周期",
type: 3
}
]
}
}
],
children: []
}
]
},
{
name: "dw.dim_seller",
id: "56_18",
type: false,
fields: [
{
name: "seller_id",
id: 213,
info: {
route: "18",
tableId: 56,
modelId: 28,
menu: [
{
name: "新建原子指标",
type: 0
},
{
name: "新建统计粒度",
type: 1
},
{
name: "新建业务限定",
type: 2
},
{
name: "新建时间周期",
type: 3
}
]
}
},
{
name: "auth_time",
id: 214,
info: {
route: "18",
tableId: 56,
modelId: 28,
menu: [
{
name: "新建原子指标",
type: 0
},
{
name: "新建统计粒度",
type: 1
},
{
name: "新建业务限定",
type: 2
},
{
name: "新建时间周期",
type: 3
}
]
}
}
],
children: [
{
name: "dw.dim_time",
id: "55_18,16",
type: false,
fields: [
{
name: "time_type",
id: 211,
info: {
route: "18,16",
tableId: 55,
modelId: 28,
menu: [
{
name: "新建原子指标",
type: 0
},
{
name: "新建统计粒度",
type: 1
},
{
name: "新建业务限定",
type: 2
},
{
name: "新建时间周期",
type: 3
}
]
}
},
{
name: "time_desc",
id: 212,
info: {
route: "18,16",
tableId: 55,
modelId: 28,
menu: [
{
name: "新建原子指标",
type: 0
},
{
name: "新建统计粒度",
type: 1
},
{
name: "新建业务限定",
type: 2
},
{
name: "新建时间周期",
type: 3
}
]
}
}
],
children: [
{
name: "dw.dim_time_desc",
id: "58_18,16,21",
type: false,
fields: [
{
name: "time_desc_id",
id: 237,
info: {
route: "18,16,21",
tableId: 58,
modelId: 28,
menu: [
{
name: "新建原子指标",
type: 0
},
{
name: "新建统计粒度",
type: 1
},
{
name: "新建业务限定",
type: 2
},
{
name: "新建时间周期",
type: 3
}
]
}
}
],
children: []
}
]
}
]
},
{
name: "dw.dim_time",
id: "55_19",
type: false,
fields: [
{
name: "time_type",
id: 211,
info: {
route: "19",
tableId: 55,
modelId: 28,
menu: [
{
name: "新建原子指标",
type: 0
},
{
name: "新建统计粒度",
type: 1
},
{
name: "新建业务限定",
type: 2
},
{
name: "新建时间周期",
type: 3
}
]
}
},
{
name: "time_desc",
id: 212,
info: {
route: "19",
tableId: 55,
modelId: 28,
menu: [
{
name: "新建原子指标",
type: 0
},
{
name: "新建统计粒度",
type: 1
},
{
name: "新建业务限定",
type: 2
},
{
name: "新建时间周期",
type: 3
}
]
}
}
],
children: [
{
name: "dw.dim_time_desc",
id: "58_19,21",
type: false,
fields: [
{
name: "time_desc_id",
id: 237,
info: {
route: "19,21",
tableId: 58,
modelId: 28,
menu: [
{
name: "新建原子指标",
type: 0
},
{
name: "新建统计粒度",
type: 1
},
{
name: "新建业务限定",
type: 2
},
{
name: "新建时间周期",
type: 3
}
]
}
}
],
children: []
}
]
},
{
name: "dw.dim_time",
id: "55_20",
type: false,
fields: [
{
name: "time_type",
id: 211,
info: {
route: "20",
tableId: 55,
modelId: 28,
menu: [
{
name: "新建原子指标",
type: 0
},
{
name: "新建统计粒度",
type: 1
},
{
name: "新建业务限定",
type: 2
},
{
name: "新建时间周期",
type: 3
}
]
}
},
{
name: "time_desc",
id: 212,
info: {
route: "20",
tableId: 55,
modelId: 28,
menu: [
{
name: "新建原子指标",
type: 0
},
{
name: "新建统计粒度",
type: 1
},
{
name: "新建业务限定",
type: 2
},
{
name: "新建时间周期",
type: 3
}
]
}
}
],
children: [
{
name: "dw.dim_time_desc",
id: "58_20,21",
type: false,
fields: [
{
name: "time_desc_id",
id: 237,
info: {
route: "20,21",
tableId: 58,
modelId: 28,
menu: [
{
name: "新建原子指标",
type: 0
},
{
name: "新建统计粒度",
type: 1
},
{
name: "新建业务限定",
type: 2
},
{
name: "新建时间周期",
type: 3
}
]
}
}
],
children: []
}
]
}
]
};
let edgeData = [
{
source: {
id: "57",
field: 216
},
target: {
id: "52_17",
field: 205
},
related: true
},
{
source: {
id: "52_17",
field: 206
},
target: {
id: "54_17,15",
field: 209
},
related: false
},
{
source: {
id: "57",
field: 215
},
target: {
id: "56_18",
field: 213
},
related: false
},
{
source: {
id: "56_18",
field: 214
},
target: {
id: "55_18,16",
field: 211
},
related: false
},
{
source: {
id: "55_18,16",
field: 212
},
target: {
id: "58_18,16,21",
field: 237
},
related: false
},
{
source: {
id: "57",
field: 217
},
target: {
id: "55_19",
field: 211
},
related: false
},
{
source: {
id: "55_19",
field: 212
},
target: {
id: "58_19,21",
field: 237
},
related: false
},
{
source: {
id: "57",
field: 218
},
target: {
id: "55_20",
field: 211
},
related: true
},
{
source: {
id: "55_20",
field: 212
},
target: {
id: "58_20,21",
field: 237
},
related: true
}
];
export default {
data() {
return {
nodeData: {},
edgeData: [],
width: 1000,
height: 500
};
},
mounted() {
setTimeout(() => {
this.nodeData = nodeData;
this.edgeData = edgeData;
}, 1000);
},
methods: {
menuClick(data) {
console.log(data.info, data.type);
}
},
components: {
// tableRelationGraph
}
};
</script>
<style scoped></style>
但是我这个包含了比较多的业务逻辑所以看起来比较乱 @LynnAlan
Most helpful comment
我封装了一个组件
使用方式如下:
但是我这个包含了比较多的业务逻辑所以看起来比较乱 @LynnAlan