最近作了一個 svg 圖表項目 記錄總結一下項目中遇到的問題和技術要點javascript
github地址css
預覽地址html
圖形工具須要對dom元素對寬高、所在位置進行計算,先提早複習一些經常使用對 dom 寬高位置計算對方法。
複製代碼
經過 HTMLElement 的屬性獲取 詳情點我java
offsetHeight
/offsetWidth
|clientHeight
/clientWidth
這offset
和client
的大概區別就是 offset
是包含邊框的而 client
不包含邊框。在項目裏面都是用的 offset
相關屬性計算的。offsetLeft
/offsetTop
獲取 dom 距離 上層有定位的父親元素 的 左邊/頂部 的距離經過 getBoundingClientRect 獲取node
該函數返回一個 Object
對象,該對象有 8 個屬性:top,left,right,bottom,width,height,x,y
react
top,left,right,bottom
對應獲取的是元素的上下左右邊界到窗口的距離 x,y
同left,top
git
width,height
獲取的元素的顯示寬高github
注意:經過 HTMLElement 獲取的值不受 CSS3 sacle
影響,獲取的依然是原始值,而經過getBoundingClientRect
獲取的是縮放元素後實際顯示的寬高,因爲這個特殊的特性,2 種方法都有實際的使用場景算法
咱們先把頁面的元素 分爲幾大類 node(節點)、port(點)、link(生成的連線)、segment(點擊一個點拖拽拉出的連線)
咱們須要實現的是typescript
根據功能咱們定義一下數據結構以下:
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
用來渲染連線
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>
複製代碼
node
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
源代碼
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
而不是其餘定位元素
在此以前咱們先複習一下 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
和 port
Mount
後把 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
就自動生成了一條 link
link
位置也會時時隨着 node
的位置更新
狀況二:起點是 port
, 終點是 port
同上 port-1
計算
到這一步驟後就可生成直線,咱們經過改變 path
d 的算法可生成曲線 源代碼
svg
畫布大小等同 link
畫布,層級高於 node畫布
,默認隱藏該層畫布。useDrag
hook 的前提,使每一個 port
擁有相應的鼠標事件。mousedown
換算 port
portDom 的中心位置 在 svg
內的座標值 而且顯示該層 svg
畫布mousemove
換算 當前鼠標所在 位置 在 svg
內的座標值,根據 起點座標,終點座標在 svg
內 繪製線條mouseup
檢測鼠標鬆開的落點若是 node
或者 port
,往 links
push 新的 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)
})
複製代碼
原理: 給整個外層容器 設置 `CSS transform translateX translateY` 屬性
複製代碼
這部分代碼比較簡單直接看源碼便可:源代碼
原理: 給整個外層容器 設置 `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
軸偏移量公式// 僞代碼
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]
)
複製代碼
原理: 鼠標移動的時候繪製框選的 `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`原理:在 `onChange` 事件到時候,把 `value` 推入 `past` 數組,在撤銷的時候把 `value` 推入 `feature` 數組
複製代碼
源代碼:useHistory hook
## 12. 待續未完
複製代碼