当前位置: 首页 > 知识库问答 >
问题:

vue.js - 求思维导图实现思路或效果?

钦楚青
2024-12-30

想实现类似“博思白板”的思维导图效果,本来想找一些现有的“mind”vue组件,但是又有比较强的定制化要求,如:

节点并非“简单”的效果,如:文本,图片等,我们需要定制化,放一些业务数据上去,所以节点要能定制化(这一点比较重要,展示的数据比较直观),找了很多组件,都是“节点”比较简单,所以大概率需要自己实现了,因为之前接触过jsPlumb(简单理解为dom节点连线组件);

自己写的话就会遇到如下问题:
1、节点自动生成定位坐标,需要考虑哪些问题;
2、添加子节点后,原有的节点树的布局(同一节点下,插入一个子节点,其他子节点的位置也需要重新计算,这个算法是否有一些常规的,不需要自己去考虑各种场景);
3、还有在线协同功能,不过这个主要是在全部功能实现完成的基础上来做的,这是第二阶段吧;

主要现在时间有限,如果选错了方向,就没时间回头了,请大佬们提供一些宝贵的意见,谢谢!!

“博思白板”效果,如下:

共有2个答案

晋骏喆
2024-12-30

就用D3.js可以,d3js可以满足你的上面说的定位坐标D3.js的力导向图布局自动处理,不用你手动计算,加新节点时候,D3会自动重新计算整个图的布局,协同的话基本上都是用WebSocket实时同步节点数据和位置的:
更新这个版本可以节点自由拖拽、可以改节点父子关系:

<!-- MindMap.vue -->
<template>
  <div class="mind-map-container" ref="container" @wheel="handleZoom">
    <div class="toolbar">
      <button @click="addRootNode">添加根节点</button>
      <button @click="resetZoom">重置缩放</button>
    </div>
    <svg class="mind-map-svg" ref="svg" :style="transformStyle">
      <!-- 连接线 -->
      <g class="links">
        <path
          v-for="link in links"
          :key="link.id"
          :d="generatePath(link)"
          class="link"
          :class="{ 'link-highlight': isLinkHighlighted(link) }"
        ></path>
      </g>
      <!-- 拖拽指示线 -->
      <path
        v-if="dragLine.visible"
        :d="dragLine.path"
        class="drag-line"
      ></path>
      <!-- 节点 -->
      <g class="nodes">
        <g
          v-for="node in nodes"
          :key="node.id"
          class="node"
          :class="{ 'node-dragging': isDragging && selectedNode?.id === node.id }"
          :transform="`translate(${node.x},${node.y})`"
          @mousedown="startDrag(node, $event)"
          @mouseover="handleNodeHover(node)"
          @mouseleave="handleNodeLeave(node)"
        >
          <foreignObject
            :width="nodeWidth"
            :height="nodeHeight"
            :x="-nodeWidth/2"
            :y="-nodeHeight/2"
          >
            <div 
              class="node-content"
              :class="{
                'node-root': !node.parentId,
                'node-selected': selectedNode?.id === node.id
              }"
            >
              <div class="node-title" @dblclick="startEdit(node)">
                <input
                  v-if="editingNode?.id === node.id"
                  ref="titleInput"
                  v-model="editingNode.name"
                  @blur="finishEdit"
                  @keyup.enter="finishEdit"
                  class="title-input"
                />
                <span v-else>{{ node.name }}</span>
              </div>
              <div class="node-controls">
                <span class="add-child" @click.stop="addChild(node)">+</span>
                <span class="delete-node" @click.stop="deleteNode(node)">×</span>
              </div>
            </div>
          </foreignObject>
        </g>
      </g>
    </svg>
  </div>
</template>

<script>
import * as d3 from 'd3'

export default {
  name: 'MindMap',
  data() {
    return {
      nodes: [],
      links: [],
      nodeWidth: 200,
      nodeHeight: 80,
      simulation: null,
      isDragging: false,
      selectedNode: null,
      hoveredNode: null,
      editingNode: null,
      scale: 1,
      translate: { x: 0, y: 0 },
      dragLine: {
        visible: false,
        path: '',
        sourceNode: null,
        targetNode: null
      }
    }
  },
  computed: {
    transformStyle() {
      return {
        transform: `translate(${this.translate.x}px, ${this.translate.y}px) scale(${this.scale})`
      }
    }
  },
  mounted() {
    this.initMindMap()
    this.initSimulation()
  },
  methods: {
    initMindMap() {
      this.nodes = [{
        id: '1',
        name: '中心主题',
        parentId: null,
        x: 0,
        y: 0,
        level: 0
      }]
    },
    initSimulation() {
      this.simulation = d3.forceSimulation(this.nodes)
        .force('link', d3.forceLink(this.links)
          .id(d => d.id)
          .distance(150)
          .strength(1)
        )
        .force('charge', d3.forceManyBody().strength(-1000))
        .force('collide', d3.forceCollide(this.nodeWidth / 2))
        .force('x', d3.forceX().strength(0.1))
        .force('y', d3.forceY().strength(0.1))
        .on('tick', this.ticked)
    },
    ticked() {
      this.nodes = [...this.nodes]
      this.links = [...this.links]
    },
    generatePath(link) {
      const sourceX = link.source.x
      const sourceY = link.source.y
      const targetX = link.target.x
      const targetY = link.target.y
      
      return `M ${sourceX} ${sourceY} 
              C ${(sourceX + targetX) / 2} ${sourceY},
                ${(sourceX + targetX) / 2} ${targetY},
                ${targetX} ${targetY}`
    },
    startDrag(node, event) {
      if (event.button !== 0) return // 只响应左键
      
      this.isDragging = true
      this.selectedNode = node
      
      const startX = event.clientX
      const startY = event.clientY
      const startNodeX = node.x
      const startNodeY = node.y
      
      const drag = (e) => {
        if (this.isDragging) {
          const dx = (e.clientX - startX) / this.scale
          const dy = (e.clientY - startY) / this.scale
          
          node.x = startNodeX + dx
          node.y = startNodeY + dy
          
          // 更新拖拽指示线
          this.updateDragLine(e)
          
          this.simulation.alpha(0.3).restart()
        }
      }
      
      const endDrag = (e) => {
        this.isDragging = false
        
        // 处理节点关系变更
        if (this.hoveredNode && this.hoveredNode !== node) {
          this.changeNodeParent(node, this.hoveredNode)
        } else if (!this.hoveredNode) {
          // 如果没有悬停在任何节点上,则变成独立节点
          this.makeNodeIndependent(node)
        }
        
        this.dragLine.visible = false
        document.removeEventListener('mousemove', drag)
        document.removeEventListener('mouseup', endDrag)
      }
      
      document.addEventListener('mousemove', drag)
      document.addEventListener('mouseup', endDrag)
    },
    updateDragLine(event) {
      if (!this.isDragging || !this.selectedNode) return
      
      const rect = this.$refs.container.getBoundingClientRect()
      const x = (event.clientX - rect.left) / this.scale - this.translate.x
      const y = (event.clientY - rect.top) / this.scale - this.translate.y
      
      this.dragLine = {
        visible: true,
        path: `M ${this.selectedNode.x} ${this.selectedNode.y} L ${x} ${y}`
      }
    },
    changeNodeParent(node, newParent) {
      // 防止循环引用
      if (this.isDescendant(node, newParent)) return
      
      // 移除旧连接
      this.links = this.links.filter(link => 
        link.target.id !== node.id
      )
      
      // 更新节点父级
      node.parentId = newParent.id
      
      // 添加新连接
      this.links.push({
        id: `${newParent.id}-${node.id}`,
        source: newParent,
        target: node
      })
      
      this.simulation.force('link').links(this.links)
      this.simulation.alpha(1).restart()
    },
    makeNodeIndependent(node) {
      if (!node.parentId) return
      
      // 移除与父节点的连接
      this.links = this.links.filter(link => 
        link.target.id !== node.id
      )
      
      node.parentId = null
      this.simulation.force('link').links(this.links)
      this.simulation.alpha(1).restart()
    },
    isDescendant(parent, child) {
      let current = child
      while (current.parentId) {
        if (current.parentId === parent.id) return true
        current = this.nodes.find(n => n.id === current.parentId)
      }
      return false
    },
    addRootNode() {
      const newNode = {
        id: Date.now().toString(),
        name: '新主题',
        parentId: null,
        x: 0,
        y: 0,
        level: 0
      }
      
      this.nodes.push(newNode)
      this.simulation.nodes(this.nodes)
      this.simulation.alpha(1).restart()
    },
    addChild(parentNode) {
      const newNode = {
        id: Date.now().toString(),
        name: '新节点',
        parentId: parentNode.id,
        x: parentNode.x + 100,
        y: parentNode.y,
        level: parentNode.level + 1
      }
      
      this.nodes.push(newNode)
      this.links.push({
        id: `${parentNode.id}-${newNode.id}`,
        source: parentNode,
        target: newNode
      })
      
      this.simulation.nodes(this.nodes)
      this.simulation.force('link').links(this.links)
      this.simulation.alpha(1).restart()
    },
    deleteNode(node) {
      // 递归删除子节点
      const deleteDescendants = (nodeId) => {
        const children = this.nodes.filter(n => n.parentId === nodeId)
        children.forEach(child => deleteDescendants(child.id))
        this.nodes = this.nodes.filter(n => n.id !== nodeId)
        this.links = this.links.filter(l => 
          l.source.id !== nodeId && l.target.id !== nodeId
        )
      }
      
      deleteDescendants(node.id)
      this.simulation.nodes(this.nodes)
      this.simulation.force('link').links(this.links)
      this.simulation.alpha(1).restart()
    },
    handleNodeHover(node) {
      if (this.isDragging && node !== this.selectedNode) {
        this.hoveredNode = node
      }
    },
    handleNodeLeave() {
      this.hoveredNode = null
    },
    startEdit(node) {
      this.editingNode = { ...node }
      this.$nextTick(() => {
        this.$refs.titleInput?.[0]?.focus()
      })
    },
    finishEdit() {
      if (this.editingNode) {
        const node = this.nodes.find(n => n.id === this.editingNode.id)
        if (node) {
          node.name = this.editingNode.name
        }
      }
      this.editingNode = null
    },
    handleZoom(event) {
      event.preventDefault()
      const delta = event.deltaY > 0 ? 0.9 : 1.1
      this.scale = Math.min(Math.max(0.1, this.scale * delta), 2)
    },
    resetZoom() {
      this.scale = 1
      this.translate = { x: 0, y: 0 }
    },
    isLinkHighlighted(link) {
      return this.hoveredNode && 
        (link.source.id === this.hoveredNode.id || 
         link.target.id === this.hoveredNode.id)
    }
  }
}
</script>

<style scoped>
.mind-map-container {
  width: 100%;
  height: 100%;
  background: #f5f5f5;
  position: relative;
  overflow: hidden;
}

.toolbar {
  position: absolute;
  top: 20px;
  left: 20px;
  z-index: 100;
}

.toolbar button {
  margin-right: 10px;
  padding: 5px 10px;
  background: #fff;
  border: 1px solid #ddd;
  border-radius: 4px;
  cursor: pointer;
}

.mind-map-svg {
  width: 100%;
  height: 100%;
  transform-origin: center;
  transition: transform 0.1s;
}

.link {
  fill: none;
  stroke: #999;
  stroke-width: 2px;
  transition: stroke 0.3s;
}

.link-highlight {
  stroke: #4CAF50;
  stroke-width: 3px;
}

.drag-line {
  fill: none;
  stroke: #4CAF50;
  stroke-width: 2px;
  stroke-dasharray: 5,5;
}

.node-content {
  background: white;
  border: 1px solid #ddd;
  border-radius: 8px;
  padding: 10px;
  width: 100%;
  height: 100%;
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  cursor: move;
  transition: all 0.3s;
}

.node-root {
  background: #4CAF50;
  color: white;
}

.node-selected {
  box-shadow: 0 0 0 2px #4CAF50;
}

.node-dragging {
  opacity: 0.7;
}

.node-title {
  font-weight: bold;
  margin-bottom: 5px;
  user-select: none;
}

.title-input {
  border: none;
  outline: none;
  text-align: center;
  width: 100%;
  background: transparent;
}

.node-controls {
  position: absolute;
  right: 5px;
  bottom: 5px;
  display: flex;
  gap: 5px;
}

.add-child,
.delete-node {
  width: 20px;
  height: 20px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  font-size: 16px;
  transition: all 0.3s;
}

.add-child {
  background: #4CAF50;
  color: white;
}

.delete-node {
  background: #f44336;
  color: white;
}

.add-child:hover,
.delete-node:hover {
  transform: scale(1.1);
}
</style>

初始版本:

<!-- MindMap.vue -->
<template>
  <div class="mind-map-container" ref="container">
    <svg class="mind-map-svg" ref="svg">
      <!-- 连接线 -->
      <g class="links">
        <path
          v-for="link in links"
          :key="link.id"
          :d="generatePath(link)"
          class="link"
        ></path>
      </g>
      <!-- 节点 -->
      <g class="nodes">
        <g
          v-for="node in nodes"
          :key="node.id"
          class="node"
          :transform="`translate(${node.x},${node.y})`"
          @mousedown="startDrag(node, $event)"
        >
          <!-- 自定义节点内容 -->
          <foreignObject
            :width="nodeWidth"
            :height="nodeHeight"
            :x="-nodeWidth/2"
            :y="-nodeHeight/2"
          >
            <div class="node-content">
              <div class="node-title">{{ node.name }}</div>
              <!-- 自定义业务数据展示 -->
              <div class="node-data" v-if="node.data">
                {{ node.data }}
              </div>
              <!-- 加子节点按钮 -->
              <div class="add-child" @click.stop="addChild(node)">+</div>
            </div>
          </foreignObject>
        </g>
      </g>
    </svg>
  </div>
</template>

<script>
import * as d3 from 'd3'

export default {
  name: 'MindMap',
  data() {
    return {
      nodes: [],
      links: [],
      nodeWidth: 200,
      nodeHeight: 100,
      simulation: null,
      dragging: false,
      selectedNode: null
    }
  },
  mounted() {
    this.initMindMap()
    this.initSimulation()
  },
  methods: {
    initMindMap() {
      // 初始化根节点
      this.nodes = [{
        id: '1',
        name: '中心主题',
        data: { /* 自定义业务数据 */ },
        x: 0,
        y: 0,
        level: 0
      }]
    },
    initSimulation() {
      // 用D3力导向图布局
      this.simulation = d3.forceSimulation(this.nodes)
        .force('link', d3.forceLink(this.links).id(d => d.id).distance(200))
        .force('charge', d3.forceManyBody().strength(-1000))
        .force('x', d3.forceX())
        .force('y', d3.forceY())
        .on('tick', this.ticked)
    },
    ticked() {
      // 更新节点和连接线位置
      this.nodes = [...this.nodes]
      this.links = [...this.links]
    },
    generatePath(link) {
      // 生成贝塞尔曲线路径
      const dx = link.target.x - link.source.x
      const dy = link.target.y - link.source.y
      const dr = Math.sqrt(dx * dx + dy * dy)
      return `M${link.source.x},${link.source.y}A${dr},${dr} 0 0,1 ${link.target.x},${link.target.y}`
    },
    addChild(parentNode) {
      const newNode = {
        id: Date.now().toString(),
        name: '新节点',
        data: {},
        x: parentNode.x + 100,
        y: parentNode.y,
        level: parentNode.level + 1
      }
      
      this.nodes.push(newNode)
      this.links.push({
        id: `${parentNode.id}-${newNode.id}`,
        source: parentNode.id,
        target: newNode.id
      })
      
      // 重新启动模拟
      this.simulation.nodes(this.nodes)
      this.simulation.force('link').links(this.links)
      this.simulation.alpha(1).restart()
    },
    startDrag(node, event) {
      this.dragging = true
      this.selectedNode = node
      
      const drag = (e) => {
        if (this.dragging && this.selectedNode) {
          this.selectedNode.x = e.clientX - this.$refs.container.offsetLeft
          this.selectedNode.y = e.clientY - this.$refs.container.offsetTop
          this.simulation.alpha(1).restart()
        }
      }
      
      const endDrag = () => {
        this.dragging = false
        this.selectedNode = null
        document.removeEventListener('mousemove', drag)
        document.removeEventListener('mouseup', endDrag)
      }
      
      document.addEventListener('mousemove', drag)
      document.addEventListener('mouseup', endDrag)
    }
  }
}
</script>

<style scoped>
.mind-map-container {
  width: 100%;
  height: 100%;
  background: #f5f5f5;
  position: relative;
  overflow: hidden;
}

.mind-map-svg {
  width: 100%;
  height: 100%;
}

.link {
  fill: none;
  stroke: #999;
  stroke-width: 2px;
}

.node-content {
  background: white;
  border: 1px solid #ddd;
  border-radius: 8px;
  padding: 10px;
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  cursor: move;
}

.node-title {
  font-weight: bold;
  margin-bottom: 5px;
}

.node-data {
  font-size: 12px;
  color: #666;
}

.add-child {
  position: absolute;
  right: 5px;
  bottom: 5px;
  width: 20px;
  height: 20px;
  background: #4CAF50;
  color: white;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
}
</style>
     
唐彦
2024-12-30

回答

实现类似“博思白板”的思维导图效果,可以考虑以下实现思路和解决方案:

1. 节点定制化

  • 使用自定义组件:由于需要展示复杂业务数据,建议为每个节点使用自定义 Vue 组件。这样可以在组件内部实现任何需要的定制化效果,如文本、图片、自定义数据展示等。
  • 数据驱动:将节点数据作为组件的 props 传入,通过 Vue 的响应式系统,当数据变化时,节点组件会自动更新。

2. 节点自动生成定位坐标

  • 布局算法:可以考虑使用常见的思维导图布局算法,如层次布局(Hierarchical Layout)或力导向布局(Force-Directed Layout)。层次布局适合展示树状结构,而力导向布局则更灵活,可以展示复杂的关系网。
  • 自动调整:在添加或删除节点时,需要重新计算所有节点的位置。可以使用递归或迭代的方式遍历节点树,根据布局算法更新节点的坐标。
  • 碰撞检测:为了避免节点重叠,可以添加碰撞检测机制。在更新节点位置后,检查节点之间是否重叠,并适当调整位置。

3. 添加子节点后的布局调整

  • 常规算法:对于树状结构,可以使用常见的树形布局算法。当添加子节点时,只需更新该子节点及其兄弟节点的位置。通常,可以使用深度优先搜索(DFS)或广度优先搜索(BFS)来遍历节点树,并根据需要调整节点的坐标。
  • 动画效果:为了提升用户体验,可以在添加子节点时添加动画效果。例如,当插入子节点时,可以逐渐展开父节点,并平滑地移动其他子节点的位置。

4. 在线协同功能

  • 实时通信:实现在线协同功能需要建立实时通信系统。可以使用 WebSocket、Firebase 或其他实时数据库来同步思维导图的状态。
  • 冲突解决:当多个用户同时编辑思维导图时,可能会发生冲突。需要设计冲突解决机制,如乐观锁定、版本控制或合并策略。
  • 权限管理:为了控制用户对思维导图的访问和编辑权限,需要实现权限管理系统。可以使用基于角色的访问控制(RBAC)或基于声明的访问控制(ABAC)来管理用户权限。

总结

  • 技术选型:建议使用 Vue.js 作为前端框架,结合 jsPlumb 或其他 DOM 操作库来实现节点连线功能。
  • 布局算法:选择适合业务需求的布局算法,并考虑在添加或删除节点时自动调整布局。
  • 定制化需求:通过自定义 Vue 组件来满足节点的定制化需求。
  • 在线协同:在实现基本功能后,逐步添加在线协同功能,包括实时通信、冲突解决和权限管理。

希望这些建议能对你有所帮助!

 类似资料:
  • 在文档内,点击右上角第一个按钮即可进入思维导图模式。 点击某节点,可进入该节点。 左下角菜单栏分别为: 放大/缩小:可以对思维导图进行放大和缩小操作; 外观样式:可以选择思维导图的结构和风格,支持4种结构和10种风格(需高级版); 导出(即将支持); 退出:回到文档编辑模式。

  • 幕布支持便捷地将文档转为思维导图。 点击右上角按钮即可进入思维导图模式。 点击某节点,可进入该节点。 右上角菜单栏分别为: 放大/缩小:可以对思维导图进行放大和缩小操作; 外观样式:可以选择思维导图的结构和风格,支持4种结构和10种风格(需高级版); 导出:将思维导图导出到「FreeMind」或导出为图片(需高级版); 退出:回到文档编辑模式。

  • 学习以下开发思路 若开发APP或者小程序的 以下这种图片程序, 1、放弃使用地图模式 2、在不同的路线会显示时间、距离 3、每个地点都会发光 应该用什么思路开发

  • 本文向大家介绍JavaScript轮播停留效果的实现思路,包括了JavaScript轮播停留效果的实现思路的使用技巧和注意事项,需要的朋友参考一下 一、思路 1.轮播停留与无线滚动十分类似,都是利用属性及变量控制移动实现轮播; 2.不同的是轮播停留需要添加过渡属性搭配定时器即可实现轮播停留效果; 二、步骤 1.写基本结构样式 需在末尾多添加一张与第一张相同的图片,消除切换时的抖动; 2.添加轮播停

  • 本文向大家介绍Python实现时钟显示效果思路详解,包括了Python实现时钟显示效果思路详解的使用技巧和注意事项,需要的朋友参考一下 语言:Python IDE:Python.IDE 1.编写时钟程序,要求根据时间动态更新 2.代码思路 需求:5个Turtle对象, 1个绘制外表盘+3个模拟表上针+1个输出文字 Step1:建立Turtle对象并初始化 Step2:静态表盘绘制 Step3:根据

  • 本文向大家介绍iOS实现新年抽奖转盘效果的思路,包括了iOS实现新年抽奖转盘效果的思路的使用技巧和注意事项,需要的朋友参考一下 临近春节,相信不少app都会加一个新的需求——新年抽奖 不多废话,先上GIF效果图 DEMO链接 1. 跑马灯效果 2. 抽奖效果 实现步骤: 一、跑马灯效果 其实很简单,就是通过以下两张图片,用NSTimer无限替换,达到跑马灯的效果 实现代码: 二、抽奖效果 1.初始

  • 基本上,小巧和实用是有冲突的,因为越要求实用就越需要各种功能,也就越无法保证小巧。为了解决这个问题,本文采用了"核心+扩展"的思路。所谓"核心"是指保证服务器正常运行必需使用的资源,比如:libc, init, httpd, postgres, libphp, sshd ... 以及各种设备文件、配置文件等等。所谓"扩展"是指非运行时必需的资源,比如:top, cat, gcc ... 等等,主要