mindmapper-vue
v0.1.7
Published
A bidirectional mindmap rendering engine based on D3.js + Vue 3
Maintainers
Readme
mindmapper-vue
基于 D3.js + Vue 3 的双向思维导图渲染引擎。
背景
是什么
mindmapper-vue 是一个思维导图布局和渲染引擎,不是一个完整的思维导图 App。它负责把树状数据变成可交互的 SVG 图,节点长什么样、连线什么颜色、点击之后干什么,全部由使用方决定。
它的设计范围:
- 双向树布局(FlexTree 算法,左右分支自动平衡)
- SVG 连线渲染(贝塞尔曲线 / 鱼骨折线)
- 画布缩放 + 拖拽
- 节点展开/折叠
- 关键词搜索 + 高亮 + 循环跳转
- 通过
nodeRenderer(HTML 字符串)或nodeComponent(Vue 组件)渲染节点内容
为什么
业务中思维导图的使用场景差异很大,同一套节点 UI 很难同时适配产品路线图、OKR 树、因子分析图、组织架构图。已有的开源方案要么内置了固定样式、要么是纯 JS 没有 Vue 集成、要么是通用图库配置成本高。
这个引擎的设计原则只有一个:引擎管布局和交互,节点内容由消费方完全控制。你传一棵树进来,得到一张可交互的图,至于每个节点卡片怎么渲染是你的事。
技术层次
消费方
nodeComponent / nodeRenderer —— 你写的节点卡片
lineColor / nodeSizer —— 你提供的配置函数
@node-click / @node-action —— 你监听的事件
mindmapper-vue 引擎
Mindmap.vue —— 组件入口
ImData + FlexTree —— 树模型和坐标计算
draw/index.ts —— D3 SVG 差量渲染(enter/update/exit)
listener/ —— zoom、drag、click、search
config.ts —— 可注入配置项
D3.js 底层
d3-zoom / d3-drag / d3-shape / d3-selection / d3-transition数据流向:Data[] → ImData(坐标计算)→ Mdata[](内部结构)→ D3 渲染为 <g> + <foreignObject> → 消费方的节点渲染函数填入内容。
安装
npm install mindmapper-vue
# 或
pnpm add mindmapper-vue依赖 Vue 3.2+(peer dependency,不会自动安装):
npm install vue@^3.2.0快速开始
最简单的用法——用 HTML 字符串渲染节点:
<template>
<div style="width: 100%; height: 600px;">
<Mindmap
:model-value="data"
:node-renderer="renderNode"
:node-sizer="sizeNode"
@node-click="onNodeClick"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Mindmap } from 'mindmapper-vue'
import 'mindmapper-vue/style.css'
import type { Mdata } from 'mindmapper-vue'
const data = ref([{
name: '根节点',
children: [
{ name: '右侧分支 A' },
{ name: '右侧分支 B' },
{ name: '左侧分支 C', left: true },
],
}])
const renderNode = (d: Mdata) => `
<div style="padding: 10px 14px; background: #fff; border: 1px solid #e5e6eb; border-radius: 8px; font-size: 13px;">
${d.name}
</div>
`
const sizeNode = () => ({ width: 180, height: 44 })
const onNodeClick = ({ data }: { data: Mdata }) => {
console.log('点击了节点:', data.name)
}
</script>注意:容器必须有确定的宽高,引擎用 100% / 100% 填满容器。
Props
数据
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| modelValue | Data[] | 必填 | 树数据,取第一个元素为根节点 |
节点渲染
两种渲染模式二选一,nodeComponent 优先级更高。
| 属性 | 类型 | 说明 |
|------|------|------|
| nodeRenderer | (d: Mdata) => string | 返回 HTML 字符串,通过 innerHTML 插入 foreignObject |
| nodeComponent | Component | 传 Vue 组件,会以 { data: Mdata } 为 props 渲染,支持完整的响应式和生命周期 |
| nodeSizer | (name: string) => { width: number; height: number } | 告知引擎节点的宽高,引擎据此计算布局。默认 160 × 40 |
nodeSizer 影响布局精度,如果你的节点卡片实际尺寸与这里返回的不一致,连线位置会错位。建议根据实际内容计算。
连线
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| linkStyle | 'curve' \| 'fishbone' | 'curve' | 连线风格。curve 是贝塞尔 S 形曲线,fishbone 是直角折线加圆角 |
| lineColor | (d: Mdata) => string | () => '#C9CDD4' | 连线颜色函数,按节点动态返回颜色 |
| strokeWidth | number | 2.5 | 连线粗细(SVG stroke-width) |
| spineOffset | number | 30 | 鱼骨模式专用,从父节点边缘到垂直脊柱的水平臂长度(px) |
布局
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| xGap | number | 300 | 节点之间的水平间距(px) |
| yGap | number | 100 | 同级节点之间的垂直间距(px) |
| scaleExtent | [number, number] | [0.1, 2] | 画布缩放范围,[最小倍率, 最大倍率] |
交互
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| drag | boolean | true | 是否允许拖拽节点 |
| zoom | boolean | true | 是否允许缩放和拖拽画布 |
| autoFitView | boolean | true | 数据变化后是否自动适应视图 |
UI
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| background | 'dots' \| 'grid' \| 'none' | 'dots' | 画布背景风格 |
| centerBtn | boolean | true | 是否显示右下角"居中"按钮 |
| fitBtn | boolean | true | 是否显示右下角"适应视图"按钮 |
事件
| 事件 | Payload | 触发时机 |
|------|---------|---------|
| node-click | { data: Mdata, event: MouseEvent } | 点击节点内没有 data-action 的区域 |
| node-action | { action: string, data: Mdata, attrs: Record<string, string>, event: MouseEvent } | 点击节点内有 data-action 属性的元素 |
| node-dblclick | Mdata, MouseEvent | 双击节点 |
| expand | Mdata | 节点展开 |
| collapse | Mdata | 节点折叠 |
data-action 协议
在 nodeRenderer 的 HTML 或 nodeComponent 的模板中,给元素加上 data-action 属性,引擎会自动收集并通过 node-action 事件抛出,不需要在子组件里手动监听事件。
<button data-action="delete" data-node-id="abc123">删除</button>
<button data-action="copy-link" data-url="https://example.com">复制链接</button>node-action 收到的 payload:
{
action: 'delete', // data-action 的值
data: Mdata, // 即当前节点的 Mdata
attrs: { nodeId: 'abc123' }, // 其他 data-* 属性,key 转 camelCase
event: MouseEvent
}注意:data-action="expand" 是保留值,引擎会拦截并处理折叠/展开,不会抛出 node-action 事件。
内置快捷键
引擎内置了以下键盘操作,无需额外配置:
| 快捷键 | 行为 |
|--------|------|
| Ctrl+F / Cmd+F | 呼出右上角搜索栏 |
| Enter(搜索栏内)| 跳转到下一个匹配项 |
| Esc | 关闭搜索栏并清除高亮 |
搜索栏会显示"当前结果 / 总结果数",支持 ↑ ↓ 按钮在结果间跳转。
画布交互行为
触控板(Mac 推荐操作方式)
- 双指滑动 → 平移画布
- 捏合手势 → 缩放画布
- 无需按住任何按键
鼠标
- 滚轮 + Ctrl → 缩放
- 左键按住拖拽空白区域 → 平移画布
节点拖拽
鼠标按下节点后直接拖动即可移动节点位置,不需要先"长按"。节点内的按钮仍然可以正常点击。
组件方法(通过 ref 调用)
<template>
<Mindmap ref="mindmapRef" :model-value="data" />
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Mindmap } from 'mindmapper-vue'
const mindmapRef = ref<InstanceType<typeof Mindmap>>()
// 搜索节点,返回 { total, current }
// 相同关键词重复调用时,自动跳到下一个匹配项
const result = mindmapRef.value?.search('关键词')
// 清除搜索高亮
mindmapRef.value?.clearHighlight()
// 平滑移动视图到指定节点
mindmapRef.value?.zoomTo(someNode)
</script>| 方法 | 签名 | 说明 |
|------|------|------|
| search | (keyword: string) => { total: number; current: number } | 搜索,高亮所有匹配项,聚焦当前项,相同关键词再次调用跳下一个 |
| clearHighlight | () => void | 清除全部高亮,重置搜索状态 |
| zoomTo | (d: Mdata) => void | 平滑缩放并移动视图,使指定节点居中 |
搜索高亮样式
引擎在找到匹配节点时,会给对应的 .node-card 元素添加以下 CSS 类:
.node-card--search-highlight— 普通匹配.node-card--search-focus— 当前聚焦的匹配项
这些样式必须在你的节点卡片组件内定义,引擎本身不注入这部分 CSS。原因是节点内容运行在 SVG foreignObject 的 HTML 命名空间中,Vue 的 scoped :deep() 无法穿透这层边界。
示例(在你的 nodeComponent 或全局样式中):
/* 普通匹配 — 蓝色描边 */
.node-card.node-card--search-highlight {
outline: 1.5px solid rgba(22, 93, 255, 0.5);
outline-offset: 2px;
}
/* 当前焦点 — 更深的蓝色 */
.node-card.node-card--search-focus {
outline: 2px solid #165DFF;
outline-offset: 2px;
}Vue 组件模式
相比 nodeRenderer(HTML 字符串),nodeComponent 支持:
- 响应式数据(
computed、watch) - 子组件和插槽
- 生命周期钩子
- CSS Modules / scoped styles
- 全局注册的插件(Element Plus、Ant Design Vue 等)
示例:
<!-- 父组件 -->
<Mindmap :model-value="data" :node-component="MyCard" />
<!-- MyCard.vue -->
<script setup lang="ts">
import type { Mdata } from 'mindmapper-vue'
const props = defineProps<{ data: Mdata }>()
</script>
<template>
<div class="node-card" :class="{ 'node-card--root': data.depth === 0 }">
<span class="title">{{ data.name }}</span>
<span v-if="data.rawData.status" class="status">{{ data.rawData.status }}</span>
<!-- data-action 在组件模板里同样有效 -->
<button data-action="open-detail" :data-id="data.id">详情</button>
</div>
</template>
<style scoped>
.node-card {
padding: 10px 14px;
background: #fff;
border: 1px solid #e5e6eb;
border-radius: 8px;
font-size: 13px;
}
.node-card--root {
background: #1c1f23;
color: #fff;
border: none;
}
/* 搜索高亮样式必须放在这里 */
.node-card.node-card--search-highlight {
outline: 1.5px solid rgba(22, 93, 255, 0.5);
outline-offset: 2px;
}
.node-card.node-card--search-focus {
outline: 2px solid #165DFF;
outline-offset: 2px;
}
</style>双向布局
left: true 的子节点会被放到根节点左侧,其他节点默认在右侧。嵌套节点会继承父节点的方向(left/right)。
const data = ref([{
name: '中心节点',
children: [
{ name: '右侧 A' },
{ name: '右侧 B' },
{ name: '左侧 X', left: true },
{ name: '左侧 Y', left: true },
],
}])默认折叠
给节点设置 collapse: true,初始渲染时子树会被折叠:
const data = ref([{
name: '根节点',
children: [{
name: '这个区域默认折叠',
collapse: true,
children: [
{ name: '子节点 1' },
{ name: '子节点 2' },
],
}],
}])用户可以通过节点内的 data-action="expand" 按钮手动展开,引擎会拦截这个 action 并执行折叠/展开逻辑。
自定义连线颜色
lineColor 接收一个函数,参数是节点的 Mdata,返回颜色字符串:
<Mindmap
:line-color="(d) => {
if (d.depth === 0) return 'transparent'
return d.left ? '#F67234' : '#165DFF'
}"
:stroke-width="3"
/>鱼骨图
<Mindmap
link-style="fishbone"
:spine-offset="40"
/>spineOffset 控制从父节点边缘到垂直脊柱的水平臂长度。所有深度的连线使用统一的 arm 计算公式,远端节点 arm 固定为 spineOffset,节点距离不足时等比缩小。
类型定义
Data — 输入数据
interface Data {
/** 节点名称,必填 */
name: string
/** 子节点 */
children?: Data[]
/** 放到左侧 */
left?: boolean
/** 默认折叠 */
collapse?: boolean
/** 业务自定义字段,引擎不关心,原样保留在 rawData 中 */
[key: string]: any
}Mdata — 引擎内部节点(只读,无需手动构造)
interface Mdata {
/** 原始输入数据引用,通过 rawData.xxx 访问业务字段 */
rawData: Data
name: string
parent: Mdata | null
children: Mdata[]
_children: Mdata[] // 被折叠的子节点
left: boolean
collapse: boolean
/** 层级路径,如 '0-1-2' */
id: string
/** 由引擎根据层级自动分配的颜色 */
color: string
gKey: number
width: number
height: number
/** 0 = 根节点 */
depth: number
x: number
y: number
dx: number
dy: number
px: number
py: number
}引擎会把 Data 中不认识的字段原样放进 rawData,在 nodeRenderer 和 nodeComponent 里通过 d.rawData.yourField 访问。
事件 Payload
interface NodeClickPayload {
data: Mdata
event: MouseEvent
}
interface NodeActionPayload {
action: string
data: Mdata | null
attrs: Record<string, string> // data-* 属性,key 转 camelCase
event: MouseEvent
}工具函数
以下函数可在组件外部独立使用:
import {
fitView,
centerView,
searchAndHighlight,
clearSearchHighlight,
zoomToNode,
} from 'mindmapper-vue'| 函数 | 说明 |
|------|------|
| fitView() | 缩放并平移画布,使所有节点完整显示在视口内 |
| centerView() | 将根节点移到视口中心 |
| searchAndHighlight(keyword) | 搜索并高亮,返回 { total, current },重复调用同一关键词跳到下一个 |
| clearSearchHighlight() | 清除高亮,重置搜索状态 |
| zoomToNode(d) | 平滑移动并缩放视图到指定节点 |
项目结构
mind-map/
├── index.ts # 库入口,统一 export
├── types.ts # 对外公开的类型
├── Mindmap.vue # 主组件,Props/Events/Slots 入口
├── config.ts # 全局配置(nodeRenderer、lineColor 等的 getter/setter)
├── interface.ts # 内部类型(Data、Mdata)
├── assistant.ts # 工具函数(fitView、search 等)
├── context.ts # 实例上下文(支持多实例隔离)
├── mitt.ts # 内部事件总线
├── data/
│ ├── ImData.ts # 树模型、坐标计算、操作方法(add/delete/move 等)
│ └── flextree/ # 改造版 Flextree 布局算法
├── draw/
│ └── index.ts # D3 enter/update/exit 渲染,foreignObject 挂载
├── attribute/
│ ├── get.ts # SVG 属性值计算(路径、位置、类名等)
│ └── set.ts # SVG 属性更新函数
├── listener/
│ ├── listener.ts # 事件处理函数(zoom/drag/click/edit)
│ └── switcher.ts # 交互开关(switchZoom/switchDrag 等)
├── variable/
│ ├── index.ts # 全局运行时状态(zoom/drag 行为对象、间距等)
│ ├── element.ts # DOM/SVG 元素引用
│ └── selection.ts # D3 Selection 引用
└── css/
└── index.ts # CSS 模块(内部 class 名常量)已知限制
全局状态:引擎当前使用模块级全局变量管理状态,同一页面多个 <Mindmap> 实例之间会互相影响。如果需要多实例,用 v-if 控制同一时间只渲染一个。
SVG / HTML 边界:foreignObject 内的 HTML 事件不会冒泡到 SVG 层。引擎通过 data-action 协议和专用 click 监听器来处理这个问题,但节点内的某些原生浏览器行为(如右键菜单、拖拽文本选中)可能与画布操作产生干扰。
样式穿透:Vue 的 scoped :deep() 选择器无法穿透 foreignObject 的 HTML 命名空间,节点内容的所有样式必须在你自己的 nodeComponent 或全局 CSS 中定义。
