前言
最近做了一个 svg 图表项目 记录总结一下项目中遇到的问题和技术要点
1.计算元素尺寸的常用方法
图形工具需要对dom元素对宽高、所在位置进行计算,先提前复习一些常用对 dom 宽高位置计算对方法。
-
通过 HTMLElement 的属性获取 详情点我
- 常用的获取元素的宽高:
offsetHeight/offsetWidth|clientHeight/clientWidth这offset和client的大概区别就是offset是包含边框的而client不包含边框。在项目里面都是用的offset相关属性计算的。 offsetLeft/offsetTop获取 dom 距离 上层有定位的父亲元素 的 左边/顶部 的距离
- 常用的获取元素的宽高:
-
通过 getBoundingClientRect 获取
该函数返回一个
Object对象,该对象有 8 个属性:top,left,right,bottom,width,height,x,y-
top,left,right,bottom对应获取的是元素的上下左右边界到窗口的距离x,y同left,top -
width,height获取的元素的显示宽高
-
注意:通过 HTMLElement 获取的值不受 CSS3 sacle 影响,获取的依然是原始值,而通过getBoundingClientRect 获取的是缩放元素后实际显示的宽高,由于这个特殊的特性,2
种方法都有实际的使用场景
2.数据结构分析
我们先把页面的元素 分为几大类
node(节点)、port(点)、link(生成的连线)、segment(点击一个点拖拽拉出的连线)
我们需要实现的是
- node 拖拽移动
- port-port 连线
- port-node 连线
- 从 port 拉出线条
根据功能我们定义一下数据结构如下:
const defaultValue = {
nodes: [
{
id: 'node-1',
coordinates: [100, 150], // 坐标 对应 left top
inputs: [], // node 中输入的点
outputs: [{ id: 'port-1', isLinked: true }], // node 中输出的点
type: 'nodeTypeInput',
data: { // node 携带自定义数据
inputValue: 'defaultValue'
}
},
{
id: 'node-2',
type: 'nodeTypeSelect',
coordinates: [400, 200],
inputs: [{ id: 'input-1', isLinked: false }],
outputs: [{ id: 'port-5', isLinked: false }],
data: {
selectValue: ''
}
}
],
links: [
{ input: 'port-1', output: 'node-2' } // 一条连线的起点终点
]
}
nodes 用来渲染 页面所有的 节点
links 用来渲染连线
3.拖拽元素到画布
- 给元素设置
draggable属性 onDragStart拖拽开始事件携带数据
// 伪代码
const handleDragStart = useCallback(
(event: any) => {
event.dataTransfer?.setData('nodeType', type)
},
[type]
)
return (
<div draggable
onDragStart = {handleDragStart} >
<div className = "node-list-text" > {label} < /div>
< /div>
)
onDrop在目标区域释放的时候,获取携带数据根据鼠标所在位置生成一个node对象,并且添加到nodes中
// 伪代码
const handleDrop = useCallback(
(event: any) => {
if (event) {
event = window.event
}
const nodeType = event.dataTransfer.getData('nodeType')
const coordinates: ICoordinateType = [event.clientX, event.clientY]
const newNode = createNode(nodeType, coordinates)
handleChange({...value, nodes: [...value.nodes, newNode]})
},
[handleChange, transform, value]
)
return <div onDrop = {handleDrop} > </div>
4.移动 node
- 将画布设为相对定位 position: relative,然后把每个
node设为绝对定位 position: absolute。 mousedown记录 鼠标按下的起点位置info.start = [event.clientX, event.clientY]mousemove计算出移动的偏移量offset = [event.clientX - info.start[0], event.clientY - info.start[1]]- 通过
offset偏移量 更新该node的coordinates
为了复用 移动的逻辑 把 mouse 移动
事件封装成 useDrag hook
在 DiagramNode 组件中更新 coordinates 源代码
5.node 、port 、link 的关系
-
一个
node内可能有多个输入和输出的port -
一条
link是port或者node所对应在svg内的坐标生成 -
我们移动的是
node修改的是node的left和top -
我们在移动的同时也要更新由
port生成的link。
所以我们需要遵循一个约定:port 是 position:relative 定位在 node 内,我们通过 node 的 left 和 top 加上 offsetLeft/offsetTop
就可以实时获取 port 当前所在的坐标。
这里需要注意的是 offsetLeft/offsetTop 是找最近的父元素,然后获取偏移量,要保证找到最近的父元素是 node 而不是其他定位元素
6.基础连线
在此之前我们先复习一下 svg 画线条的原理:在 svg 画布中,只需要 起点 start = "x,y" 和终点 end = "x,y" 坐标 通过 <path /> 标签设置 d
属性 M ${start}, ${end} 就可以生成一条直线
<svg>
<path d="M 0,0, 50,80" stroke="red"/>
</svg>
该代码可以在 svg 元素下 生成起点 坐标 (0,0) 终点为 (50,80) 的红色直线,注意 坐标的 原点是 svg 元素的左上角
而连线是根据 links 数据生成,一条 link 由 input(起点) output (终点) 组成,所以我们只需要知道 起点元素 在 svg 内的坐标,即可把线画出来。
- 建立
svg画布大小等同node画布,层级小于node画布,这个简单在 同一个 div 容器下,都用相对定位宽高 100% 即可。(这样才能保证不在同一容器内的线和点,计算位置是相同的) - 第一步:在每个
node和portMount后把dom根据id储存起来
// 伪代码 存储 node dom 节点 存储 port 同理
const nodeRefs = useRef({})
const ref = useRef()
useEffect(() => {
nodeRefs[nodeId] = ref.current
}, [nodeId, ref])
return <div className="node" ref={ref}></div>
-
然后我们将分成两种情况
-
情况一:起点是
port, 终点是node例:[{input: 'port-1', output: 'node-1'}]起点
port-1的位置计算方法:- 找到
port-1父元素node的coordinates坐标 源代码 - 找到
port-1的 dom 节点port1Dom - 得出
port-1的坐标 为[coordinates[0] + port1Dom.offsetLeft + port1Dom.offsetWidth / 2, coordinates[1] + port1Dom.offsetTop + port1Dom.offsetHeight / 2]源代码
终点
node-1的位置计算方法(node 连接位置为左边的中间):- 找到
node-1父元素node的coordinates坐标 - 找到
node-1的 dom 节点node1Dom - 得出
node-1的坐标 为[coordinates[0], coordinates[1] + node1Dom.offsetHeight / 2]
拿到起点终点坐标后 设置
svg的d就自动生成了一条linklink位置也会时时随着node的位置更新 - 找到
-
情况二:起点是
port, 终点是port同上port-1计算
-
到这一步骤后就可生成直线,我们通过改变 path d
的算法可生成曲线 源代码
7.新增连线
- 建立
svg画布大小等同link画布,层级高于node画布,默认隐藏该层画布。 - 在我们已经有了
useDraghook 的前提,使每个port拥有相应的鼠标事件。 mousedown换算portportDom 的中心位置 在svg内的坐标值 并且显示该层svg画布mousemove换算 当前鼠标所在 位置 在svg内的坐标值,根据 起点坐标,终点坐标在svg内 绘制线条mouseup检测鼠标松开的落点如果node或者port,往linkspush 新的link
onDragStart((event: MouseEvent) => {
if (canvasRef && ref.current) {
// 这里通过 getBoundingClientRect
const { x, y, width, height } = ref.current.getBoundingClientRect()
// 设置连线起点为 port 中心位置
startCoordinatesRef.current = [(x + width / 2), (y + height / 2)]
}
})
onDrag((event: MouseEvent) => {
if (startCoordinatesRef.current) {
event.stopImmediatePropagation()
event.stopPropagation()
const to: ICoordinateType = calculatingCoordinates(event, canvasRef)
onDragNewSegment(id, startCoordinatesRef.current, to)
}
})
onDragEnd((event: MouseEvent) => {
const targetDom = event.target
as
HTMLElement
const targetIsPort = targetDom.classList.contains('diagram-port')
// 如果目标元素是 port 区域 并且不是起点port
if (targetIsPort && targetDom.id !== id) {
onSegmentConnect(id, targetDom.id)
return
}
// 如果目标元素是 node 区域 并非不是起点node
const targetNode = findEventTargetParentNodeId(event.target
as
HTMLElement
)
if (targetNode && targetNode !== nodeId) {
onSegmentConnect(id, targetNode)
return
}
// 否则在空白区域松开 释放
onSegmentFail && onSegmentFail(id, type)
})
8.平移画布
原理: 给整个外层容器 设置 `CSS transform translateX translateY` 属性
- 绑定键盘事件当按下 空格键 开启点击空白区域可拖动画布
- 鼠标按下时候检测是否在空白画布区域,如果是记录按下时候的位置
- 鼠标移动的时候计算偏移量 同步更新 transform
这部分代码比较简单直接看源码即可:源代码
9.鼠标中心缩放画布
原理: 给整个外层容器 设置 `CSS transform: matrix(${scale},0,0,${scale},${translateX},${translateY})` 属性
- 滚动滚轮 实现缩放 只需要 根据
event.nativeEvent.wheelDelta大于还是小于0 判断是放到还是缩小即可 - 根据鼠标 所在区域 进行放大缩小 需要在 更新
rotate的同时修改 translateX translateY的值 - 首先设置 缩放元素的
css transform-origin: 0 0 0缩放中心中心为 左上角顶点 - 通过滚轮事件的
wheelDelta的正负区分是放大或缩小 - 通过鼠标在 左下角 进行缩放 可以得出公式 缩放时
Y轴偏移量 等于((event.clientY - translateY) * SCALE_STEP) / scale - 同理:通过鼠标在 右上角 进行缩放 可得出 缩放时
X轴偏移量公式 - 更新 transform 数据
// 伪代码
const SCALE_STEP = 0.1 // 每次缩放 的步长
const handleWheel = useCallback(
(event: any) => {
const wheelDelta = event.nativeEvent.wheelDelta
let { scale, translateX, translateY } = transform
// 偏移量计算公式
const offsetX = ((event.clientX - translateX) * SCALE_STEP) / scale
const offsetY = ((event.clientY - translateY) * SCALE_STEP) / scale
if (wheelDelta < 0) {
scale = scale - SCALE_STEP
translateX = translateX + offsetX
translateY = translateY + offsetY
}
if (wheelDelta > 0) {
scale = scale + SCALE_STEP
translateX = translateX - offsetX
translateY = translateY - offsetY
}
if (scale > 1 || scale < 0.1) return
setTransform({
scale: Number(scale.toFixed(2)),
translateX,
translateY
})
},
[handleThrottleSetTransform, transform]
)
10.框选
原理: 鼠标移动的时候绘制框选的 `div` ,并且检测页面其他 `node` 和选框 `div` 是否相交
- 绘制选框核心代码
setSelectionArea({
left: Math.min(e.clientX, mouseDownStartPosition.current.x) - panelRect.x,
top: Math.min(e.clientY, mouseDownStartPosition.current.y) - panelRect.y,
width: Math.abs(e.clientX - mouseDownStartPosition.current.x),
height: Math.abs(e.clientY - mouseDownStartPosition.current.y)
})
2.碰撞检测 检测两个div 是否相交 原理:
export const collideCheck = (dom1: HTMLElement | null, dom2: HTMLElement | null) => {
if (dom1 && dom2) {
const rect1 = dom1.getBoundingClientRect()
const rect2 = dom2.getBoundingClientRect()
const maxX: number = Math.max(rect1.x + rect1.width, rect2.x + rect2.width)
const maxY: number = Math.max(rect1.y + rect1.height, rect2.y + rect2.height)
const minX: number = Math.min(rect1.x, rect2.x)
const minY: number = Math.min(rect1.y, rect2.y)
return maxX - minX <= rect1.width + rect2.width && maxY - minY <= rect1.height + rect2.height
}
return false
}
- 在鼠标
moving时候遍历 所有node进行碰撞检测,追加到activeId`
11.撤销重做
原理:在 `onChange` 事件到时候,把 `value` 推入 `past` 数组,在撤销的时候把 `value` 推入 `feature` 数组
源代码:useHistory hook
## 12. 待续未完