MindMapping.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516
  1. <template>
  2. <div ref="container" class="mindmap-container"></div>
  3. </template>
  4. <script setup>
  5. import MindMappingData from './MindMappingData.json';
  6. import { onMounted, ref } from 'vue';
  7. import { Rect, Text } from '@antv/g';
  8. import {
  9. Badge,
  10. BaseBehavior,
  11. BaseNode,
  12. BaseTransform,
  13. CommonEvent,
  14. CubicHorizontal,
  15. ExtensionCategory,
  16. Graph,
  17. GraphEvent,
  18. idOf,
  19. NodeEvent,
  20. positionOf,
  21. register,
  22. treeToGraphData
  23. } from '@antv/g6';
  24. const container = ref(null);
  25. // 定义样式
  26. const RootNodeStyle = {
  27. fill: '#EFF0F0',
  28. labelFill: '#262626',
  29. labelFontSize: 24,
  30. labelFontWeight: 600,
  31. labelOffsetY: 8,
  32. labelPlacement: 'center',
  33. ports: [{ placement: 'right' }, { placement: 'left' }],
  34. radius: 8
  35. };
  36. const NodeStyle = {
  37. fill: 'transparent',
  38. labelPlacement: 'center',
  39. labelFontSize: 16,
  40. ports: [{ placement: 'right-bottom' }, { placement: 'left-bottom' }]
  41. };
  42. const TreeEvent = {
  43. COLLAPSE_EXPAND: 'collapse-expand',
  44. ADD_CHILD: 'add-child'
  45. };
  46. let textShape;
  47. const measureText = (text) => {
  48. if (!textShape) textShape = new Text({ style: text });
  49. textShape.attr(text);
  50. return textShape.getBBox().width;
  51. };
  52. const getNodeWidth = (nodeId, isRoot) => {
  53. const padding = isRoot ? 40 : 30;
  54. const nodeStyle = isRoot ? RootNodeStyle : NodeStyle;
  55. return measureText({ text: nodeId, fontSize: nodeStyle.labelFontSize, fontFamily: 'Gill Sans' }) + padding;
  56. };
  57. const getNodeSize = (nodeId, isRoot) => {
  58. const width = getNodeWidth(nodeId, isRoot);
  59. const height = isRoot ? 48 : 32;
  60. return [width, height];
  61. };
  62. // 自定义节点类
  63. class MindmapNode extends BaseNode {
  64. static defaultStyleProps = {
  65. showIcon: false
  66. };
  67. constructor(options) {
  68. Object.assign(options.style, MindmapNode.defaultStyleProps);
  69. super(options);
  70. }
  71. get childrenData() {
  72. return this.context.model.getChildrenData(this.id);
  73. }
  74. get rootId() {
  75. return idOf(this.context.model.getRootsData()[0]);
  76. }
  77. isShowCollapse(attributes) {
  78. const { collapsed, showIcon } = attributes;
  79. return !collapsed && showIcon && this.childrenData.length > 0;
  80. }
  81. drawCircleShape(attributes, container) {
  82. const { color = '#000', radius = 10 } = attributes; // 默认颜色和半径
  83. const [width, height] = this.getSize(attributes);
  84. const circleStyle = {
  85. fill: color,
  86. stroke: '#fff',
  87. lineWidth: 1,
  88. r: radius,
  89. x: width / 2, // 圆心水平位置
  90. y: height / 2 // 圆心垂直位置
  91. };
  92. this.upsert('circle', 'circle', circleStyle, container);
  93. }
  94. getCollapseStyle(attributes) {
  95. const { showIcon, color, direction } = attributes;
  96. if (!this.isShowCollapse(attributes)) return false;
  97. const [width, height] = this.getSize(attributes);
  98. return {
  99. backgroundFill: color,
  100. backgroundHeight: 12,
  101. backgroundWidth: 12,
  102. cursor: 'pointer',
  103. fill: '#fff',
  104. fontFamily: 'iconfont',
  105. fontSize: 8,
  106. text: '\ue6e4',
  107. textAlign: 'center',
  108. transform: direction === 'left' ? [['rotate', 90]] : [['rotate', -90]],
  109. visibility: showIcon ? 'visible' : 'hidden',
  110. x: direction === 'left' ? -6 : width + 6,
  111. y: height
  112. };
  113. }
  114. drawCollapseShape(attributes, container) {
  115. const iconStyle = this.getCollapseStyle(attributes);
  116. const btn = this.upsert('collapse-expand', Badge, iconStyle, container);
  117. this.forwardEvent(btn, CommonEvent.CLICK, (event) => {
  118. event.stopPropagation();
  119. this.context.graph.emit(TreeEvent.COLLAPSE_EXPAND, {
  120. id: this.id,
  121. collapsed: !attributes.collapsed
  122. });
  123. });
  124. }
  125. getCountStyle(attributes) {
  126. const { collapsed, color, direction } = attributes;
  127. const count = this.context.model.getDescendantsData(this.id).length;
  128. if (!collapsed || count === 0) return false;
  129. const [width, height] = this.getSize(attributes);
  130. return {
  131. backgroundFill: color,
  132. backgroundHeight: 12,
  133. backgroundWidth: 12,
  134. cursor: 'pointer',
  135. fill: '#fff',
  136. fontSize: 8,
  137. text: count.toString(),
  138. textAlign: 'center',
  139. x: direction === 'left' ? -6 : width + 6,
  140. y: height
  141. };
  142. }
  143. drawCountShape(attributes, container) {
  144. const countStyle = this.getCountStyle(attributes);
  145. const btn = this.upsert('count', Badge, countStyle, container);
  146. this.forwardEvent(btn, CommonEvent.CLICK, (event) => {
  147. event.stopPropagation();
  148. this.context.graph.emit(TreeEvent.COLLAPSE_EXPAND, {
  149. id: this.id,
  150. collapsed: false
  151. });
  152. });
  153. }
  154. getAddStyle(attributes) {
  155. const { collapsed, showIcon, direction } = attributes;
  156. if (collapsed || !showIcon) return false;
  157. const [width, height] = this.getSize(attributes);
  158. const color = '#ddd';
  159. const offsetX = this.isShowCollapse(attributes) ? 24 : 12;
  160. const isRoot = this.id === this.rootId;
  161. return {
  162. backgroundFill: '#fff',
  163. backgroundHeight: 12,
  164. backgroundLineWidth: 1,
  165. backgroundStroke: color,
  166. backgroundWidth: 12,
  167. cursor: 'pointer',
  168. fill: color,
  169. fontFamily: 'iconfont',
  170. fontSize: 8,
  171. text: '\ue664',
  172. textAlign: 'center',
  173. x: isRoot ? width + 12 : direction === 'left' ? -offsetX : width + offsetX,
  174. y: isRoot ? height / 2 : height
  175. };
  176. }
  177. getAddBarStyle(attributes) {
  178. const { collapsed, showIcon, direction, color = '#1783FF' } = attributes;
  179. if (collapsed || !showIcon) return false;
  180. const [width, height] = this.getSize(attributes);
  181. const offsetX = this.isShowCollapse(attributes) ? 12 : 0;
  182. const isRoot = this.id === this.rootId;
  183. const HEIGHT = 2;
  184. const WIDTH = 6;
  185. return {
  186. cursor: 'pointer',
  187. fill:
  188. direction === 'left'
  189. ? `linear-gradient(180deg, #fff 20%, ${color})`
  190. : `linear-gradient(0deg, #fff 20%, ${color})`,
  191. height: HEIGHT,
  192. width: WIDTH,
  193. x: isRoot ? width : direction === 'left' ? -offsetX - WIDTH : width + offsetX,
  194. y: isRoot ? height / 2 - HEIGHT / 2 : height - HEIGHT / 2,
  195. zIndex: -1
  196. };
  197. }
  198. drawAddShape(attributes, container) {
  199. const addStyle = this.getAddStyle(attributes);
  200. const addBarStyle = this.getAddBarStyle(attributes);
  201. this.upsert('add-bar', Rect, addBarStyle, container);
  202. const btn = this.upsert('add', Badge, addStyle, container);
  203. this.forwardEvent(btn, CommonEvent.CLICK, (event) => {
  204. event.stopPropagation();
  205. this.context.graph.emit(TreeEvent.ADD_CHILD, { id: this.id, direction: attributes.direction });
  206. });
  207. }
  208. forwardEvent(target, type, listener) {
  209. if (target && !Reflect.has(target, '__bind__')) {
  210. Reflect.set(target, '__bind__', true);
  211. target.addEventListener(type, listener);
  212. }
  213. }
  214. getKeyStyle(attributes) {
  215. const [width, height] = this.getSize(attributes);
  216. const keyShape = super.getKeyStyle(attributes);
  217. return { width, height, ...keyShape };
  218. }
  219. drawKeyShape(attributes, container) {
  220. const keyStyle = this.getKeyStyle(attributes);
  221. return this.upsert('key', Rect, keyStyle, container);
  222. }
  223. render(attributes = this.parsedAttributes, container = this) {
  224. super.render(attributes, container);
  225. this.drawCollapseShape(attributes, container);
  226. this.drawAddShape(attributes, container);
  227. this.drawCountShape(attributes, container);
  228. }
  229. }
  230. // 自定义边类
  231. class MindmapEdge extends CubicHorizontal {
  232. get rootId() {
  233. return idOf(this.context.model.getRootsData()[0]);
  234. }
  235. getKeyPath(attributes) {
  236. const path = super.getKeyPath(attributes);
  237. const isRoot = this.targetNode.id === this.rootId;
  238. const labelWidth = getNodeWidth(this.targetNode.id, isRoot);
  239. const [, tp] = this.getEndpoints(attributes);
  240. const sign = this.sourceNode.getCenter()[0] < this.targetNode.getCenter()[0] ? 1 : -1;
  241. return [...path, ['L', tp[0] + labelWidth * sign, tp[1]]];
  242. }
  243. }
  244. // 自定义行为类
  245. class CollapseExpandTree extends BaseBehavior {
  246. constructor(context, options) {
  247. super(context, options);
  248. this.bindEvents();
  249. }
  250. update(options) {
  251. this.unbindEvents();
  252. super.update(options);
  253. this.bindEvents();
  254. }
  255. bindEvents() {
  256. const { graph } = this.context;
  257. graph.on(NodeEvent.POINTER_ENTER, this.showIcon);
  258. graph.on(NodeEvent.POINTER_LEAVE, this.hideIcon);
  259. graph.on(TreeEvent.COLLAPSE_EXPAND, this.onCollapseExpand);
  260. graph.on(TreeEvent.ADD_CHILD, this.addChild);
  261. }
  262. unbindEvents() {
  263. const { graph } = this.context;
  264. graph.off(NodeEvent.POINTER_ENTER, this.showIcon);
  265. graph.off(NodeEvent.POINTER_LEAVE, this.hideIcon);
  266. graph.off(TreeEvent.COLLAPSE_EXPAND, this.onCollapseExpand);
  267. graph.off(TreeEvent.ADD_CHILD, this.addChild);
  268. }
  269. status = 'idle';
  270. showIcon = (event) => {
  271. this.setIcon(event, true);
  272. };
  273. hideIcon = (event) => {
  274. this.setIcon(event, false);
  275. };
  276. setIcon = (event, show) => {
  277. if (this.status !== 'idle') return;
  278. const { target } = event;
  279. const id = target.id;
  280. const { graph, element } = this.context;
  281. graph.updateNodeData([{ id, style: { showIcon: show } }]);
  282. element.draw({ animation: false, silence: true });
  283. };
  284. onCollapseExpand = async (event) => {
  285. this.status = 'busy';
  286. const { id, collapsed } = event;
  287. const { graph } = this.context;
  288. await graph.frontElement(id);
  289. if (collapsed) await graph.collapseElement(id);
  290. else await graph.expandElement(id);
  291. this.status = 'idle';
  292. };
  293. addChild = async (event) => {
  294. this.status = 'busy';
  295. const {
  296. onCreateChild = () => {
  297. const currentTime = new Date(Date.now()).toLocaleString();
  298. return { id: `新节点 in ${currentTime}` };
  299. }
  300. } = this.options;
  301. const { graph } = this.context;
  302. const datum = onCreateChild(event.id);
  303. const parent = graph.getNodeData(event.id);
  304. graph.addNodeData([datum]);
  305. graph.addEdgeData([{ source: event.id, target: datum.id }]);
  306. graph.updateNodeData([
  307. {
  308. id: event.id,
  309. children: [...(parent.children || []), datum.id],
  310. style: { collapsed: false, showIcon: false }
  311. }
  312. ]);
  313. await graph.render();
  314. await graph.focusElement(datum.id);
  315. this.status = 'idle';
  316. };
  317. }
  318. // 自定义变换类
  319. class AssignColorByBranch extends BaseTransform {
  320. static defaultOptions = {
  321. colors: [
  322. '#80B4FF', // 淡蓝色
  323. '#FFC8A2', // 淡橙色
  324. '#E6C8FF', // 淡紫色
  325. '#A2E6E6', // 淡青色
  326. '#C8B4FF', // 淡紫色
  327. '#FFD878', // 淡黄色
  328. '#A2E680', // 淡绿色
  329. '#FFA2D8', // 淡粉色
  330. '#80C8D8', // 淡蓝色
  331. '#80E6A2', // 淡绿色
  332. '#FFD878', // 淡橙色
  333. '#D880D8', // 淡紫色
  334. '#80C880', // 淡绿色
  335. '#FFA280', // 淡红色
  336. '#80E6E6', // 淡青色
  337. '#FFA2D8', // 淡粉色
  338. '#C8C8D8', // 淡蓝色
  339. '#FFC8C8', // 淡红色
  340. '#FFD878', // 淡黄色
  341. '#C8FF80' // 淡绿色
  342. ] // 增加更多颜色以避免重复
  343. };
  344. constructor(context, options) {
  345. super(context, Object.assign({}, AssignColorByBranch.defaultOptions, options));
  346. }
  347. beforeDraw(input) {
  348. const nodes = this.context.model.getNodeData();
  349. const edges = this.context.model.getEdgeData();
  350. if (nodes.length === 0) return input;
  351. let colorIndex = 0;
  352. // 为每个节点分配一个独立的颜色
  353. nodes.forEach((node) => {
  354. node.style ||= {};
  355. node.style.color = this.options.colors[colorIndex++ % this.options.colors.length];
  356. });
  357. // 为每条边分配颜色,颜色与目标节点的颜色一致
  358. edges.forEach((edge) => {
  359. const targetNode = nodes.find((node) => node.id === edge.target);
  360. if (targetNode) {
  361. edge.style ||= {};
  362. edge.style.stroke = targetNode.style.color;
  363. }
  364. });
  365. return input;
  366. }
  367. }
  368. // 注册扩展
  369. register(ExtensionCategory.NODE, 'mindmap', MindmapNode);
  370. register(ExtensionCategory.EDGE, 'mindmap', MindmapEdge);
  371. register(ExtensionCategory.BEHAVIOR, 'collapse-expand-tree', CollapseExpandTree);
  372. register(ExtensionCategory.TRANSFORM, 'assign-color-by-branch', AssignColorByBranch);
  373. // 获取节点方向
  374. const getNodeSide = (nodeData, parentData) => {
  375. if (!parentData) return 'center';
  376. const nodePositionX = positionOf(nodeData)[0];
  377. const parentPositionX = positionOf(parentData)[0];
  378. return parentPositionX > nodePositionX ? 'left' : 'right';
  379. };
  380. // 初始化图表
  381. onMounted(async () => {
  382. // const response = await fetch('https://assets.antv.antgroup.com/g6/algorithm-category.json');
  383. // const data = await response.json();
  384. const data = MindMappingData;
  385. const rootId = data.id;
  386. const graph = new Graph({
  387. container: container.value,
  388. width: container.value.clientWidth || 1000, // 画布宽度,默认值800
  389. height: container.value.clientHeight || 800, // 画布高度,默认值600
  390. fitView: true, // 自动缩放以适应视图
  391. fitCenter: true, // 自动居中
  392. data: treeToGraphData(data),
  393. node: {
  394. type: 'mindmap',
  395. style: function (d) {
  396. const direction = getNodeSide(d, this.getParentData(idOf(d), 'tree'));
  397. const isRoot = idOf(d) === rootId;
  398. return {
  399. direction,
  400. labelText: idOf(d),
  401. size: getNodeSize(idOf(d), isRoot),
  402. labelFontFamily: 'Gill Sans',
  403. labelBackground: true,
  404. labelBackgroundFill: 'transparent',
  405. labelPadding: direction === 'left' ? [2, 0, 10, 40] : [2, 40, 10, 0],
  406. ...(isRoot ? RootNodeStyle : NodeStyle)
  407. };
  408. }
  409. },
  410. edge: {
  411. type: 'mindmap',
  412. style: {
  413. lineWidth: 3,
  414. stroke: function (data) {
  415. return this.getNodeData(data.target).style.color || '#99ADD1';
  416. }
  417. }
  418. },
  419. layout: {
  420. type: 'mindmap',
  421. direction: 'H',
  422. getHeight: () => 80,
  423. getWidth: (node) => getNodeWidth(node.id, node.id === rootId),
  424. getVGap: () => 6,
  425. getHGap: () => 60,
  426. animation: false,
  427. // 新增配置项,使图表从左往右发散
  428. getSide: () => 'right'
  429. },
  430. behaviors: ['drag-canvas', 'zoom-canvas', 'collapse-expand-tree'],
  431. transforms: ['assign-color-by-branch'],
  432. animation: {
  433. duration: 200 // 动画时长,单位为毫秒
  434. }
  435. });
  436. graph.once(GraphEvent.AFTER_RENDER, () => {
  437. graph.fitView();
  438. });
  439. graph.render();
  440. });
  441. </script>
  442. <style scoped>
  443. .mindmap-container {
  444. width: 100%;
  445. height: 100%;
  446. }
  447. </style>