想实现类似“博思白板”的思维导图效果,本来想找一些现有的“mind”vue组件,但是又有比较强的定制化要求,如:
节点并非“简单”的效果,如:文本,图片等,我们需要定制化,放一些业务数据上去,所以节点要能定制化(这一点比较重要,展示的数据比较直观),找了很多组件,都是“节点”比较简单,所以大概率需要自己实现了,因为之前接触过jsPlumb(简单理解为dom节点连线组件);
自己写的话就会遇到如下问题:
1、节点自动生成定位坐标,需要考虑哪些问题;
2、添加子节点后,原有的节点树的布局(同一节点下,插入一个子节点,其他子节点的位置也需要重新计算,这个算法是否有一些常规的,不需要自己去考虑各种场景);
3、还有在线协同功能,不过这个主要是在全部功能实现完成的基础上来做的,这是第二阶段吧;
主要现在时间有限,如果选错了方向,就没时间回头了,请大佬们提供一些宝贵的意见,谢谢!!
“博思白板”效果,如下:
就用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>
实现类似“博思白板”的思维导图效果,可以考虑以下实现思路和解决方案:
希望这些建议能对你有所帮助!
在文档内,点击右上角第一个按钮即可进入思维导图模式。 点击某节点,可进入该节点。 左下角菜单栏分别为: 放大/缩小:可以对思维导图进行放大和缩小操作; 外观样式:可以选择思维导图的结构和风格,支持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 ... 等等,主要