MindMapping.vue 14 KB

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