Svg圖表畫板技術開發筆記

前言

最近作了一個 svg 圖表項目 記錄總結一下項目中遇到的問題和技術要點javascript

Mar-13-2021 20-19-05.gif

github地址css

預覽地址html

1.計算元素尺寸的經常使用方法

圖形工具須要對dom元素對寬高、所在位置進行計算,先提早複習一些經常使用對 dom 寬高位置計算對方法。
複製代碼
  1. 經過 HTMLElement 的屬性獲取 詳情點我java

    • 經常使用的獲取元素的寬高:offsetHeight/offsetWidth|clientHeight/clientWidthoffsetclient的大概區別就是 offset 是包含邊框的而 client 不包含邊框。在項目裏面都是用的 offset 相關屬性計算的。
    • offsetLeft/offsetTop 獲取 dom 距離 上層有定位的父親元素 的 左邊/頂部 的距離
  2. 經過 getBoundingClientRect 獲取node

    該函數返回一個 Object 對象,該對象有 8 個屬性:top,left,right,bottom,width,height,x,yreact

    • top,left,right,bottom 對應獲取的是元素的上下左右邊界到窗口的距離 x,yleft,topgit

    • width,height 獲取的元素的顯示寬高github

注意:經過 HTMLElement 獲取的值不受 CSS3 sacle 影響,獲取的依然是原始值,而經過getBoundingClientRect 獲取的是縮放元素後實際顯示的寬高,因爲這個特殊的特性,2 種方法都有實際的使用場景算法

2.數據結構分析

咱們先把頁面的元素 分爲幾大類 node(節點)、port(點)、link(生成的連線)、segment(點擊一個點拖拽拉出的連線) 咱們須要實現的是typescript

  • 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.拖拽元素到畫布

  1. 給元素設置 draggable 屬性
  2. 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>
)
複製代碼

源代碼

  1. 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

  1. 將畫布設爲相對定位 position: relative,而後把每一個 node 設爲絕對定位 position: absolute。
  2. mousedown 記錄 鼠標按下的起點位置 info.start = [event.clientX, event.clientY]
  3. mousemove 計算出移動的偏移量 offset = [event.clientX - info.start[0], event.clientY - info.start[1]]
  4. 經過 offset 偏移量 更新該 nodecoordinates

爲了複用 移動的邏輯 把 mouse 移動 事件封裝成 useDrag hook

DiagramNode 組件中更新 coordinates 源代碼

5.nodeportlink 的關係

  • 一個 node 內可能有多個輸入和輸出的 port

  • 一條 linkport 或者 node 所對應在 svg 內的座標生成

  • 咱們移動的是 node 修改的是 nodelefttop

  • 咱們在移動的同時也要更新由 port 生成的 link

因此咱們須要遵循一個約定:port 是 position:relative 定位在 node 內,咱們經過 nodelefttop 加上 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 數據生成,一條 linkinput(起點) output (終點) 組成,因此咱們只須要知道 起點元素 在 svg 內的座標,便可把線畫出來。

  1. 創建 svg 畫布大小等同 node 畫布,層級小於 node畫布,這個簡單在 同一個 div 容器下,都用相對定位寬高 100% 便可。(這樣才能保證不在同一容器內的線和點,計算位置是相同的)
  2. 第一步:在每一個 nodeport 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>
複製代碼
  1. 而後咱們將分紅兩種狀況

    1. 狀況一:起點是 port, 終點是 node 例: [{input: 'port-1', output: 'node-1'}]

      起點 port-1 的位置計算方法:

      1. 找到 port-1 父元素 nodecoordinates 座標 源代碼
      2. 找到 port-1 的 dom 節點 port1Dom
      3. 得出 port-1 的座標 爲 [coordinates[0] + port1Dom.offsetLeft + port1Dom.offsetWidth / 2, coordinates[1] + port1Dom.offsetTop + port1Dom.offsetHeight / 2] 源代碼

      終點 node-1 的位置計算方法(node 鏈接位置爲左邊的中間):

      1. 找到 node-1 父元素 nodecoordinates 座標
      2. 找到 node-1 的 dom 節點 node1Dom
      3. 得出 node-1 的座標 爲 [coordinates[0], coordinates[1] + node1Dom.offsetHeight / 2]

      拿到起點終點座標後 設置 svgd 就自動生成了一條 link link 位置也會時時隨着 node 的位置更新

    2. 狀況二:起點是 port, 終點是 port 同上 port-1 計算

到這一步驟後就可生成直線,咱們經過改變 path d 的算法可生成曲線 源代碼

7.新增連線

  1. 創建 svg 畫布大小等同 link 畫布,層級高於 node畫布,默認隱藏該層畫布。
  2. 在咱們已經有了 useDrag hook 的前提,使每一個 port 擁有相應的鼠標事件。
  3. mousedown 換算 port portDom 的中心位置 在 svg 內的座標值 而且顯示該層 svg 畫布
  4. mousemove 換算 當前鼠標所在 位置 在 svg 內的座標值,根據 起點座標,終點座標在 svg 內 繪製線條
  5. 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)
})
複製代碼

源代碼

8.平移畫布

原理: 給整個外層容器 設置 `CSS transform translateX translateY` 屬性
複製代碼
  1. 綁定鍵盤事件當按下 空格鍵 開啓點擊空白區域可拖動畫布
  2. 鼠標按下時候檢測是否在空白畫布區域,若是是記錄按下時候的位置
  3. 鼠標移動的時候計算偏移量 同步更新 transform

這部分代碼比較簡單直接看源碼便可:源代碼

9.鼠標中心縮放畫布

原理: 給整個外層容器 設置 `CSS transform: matrix(${scale},0,0,${scale},${translateX},${translateY})` 屬性
複製代碼
  1. 滾動滾輪 實現縮放 只須要 根據 event.nativeEvent.wheelDelta 大於仍是小於0 判斷是放到仍是縮小便可
  2. 根據鼠標 所在區域 進行放大縮小 須要在 更新 rotate 的同時修改 translateX translateY的值
  3. 首先設置 縮放元素的 css transform-origin: 0 0 0 縮放中心中心爲 左上角頂點
  4. 經過滾輪事件的 wheelDelta 的正負區分是放大或縮小
  5. 經過鼠標在 左下角 進行縮放 能夠得出公式 縮放時 Y軸偏移量 等於 ((event.clientY - translateY) * SCALE_STEP) / scale
  6. 同理:經過鼠標在 右上角 進行縮放 可得出 縮放時 X 軸偏移量公式
  7. 更新 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` 是否相交
複製代碼
  1. 繪製選框核心代碼
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
}
複製代碼
  1. 在鼠標 moving 時候遍歷 全部 node 進行碰撞檢測,追加到 activeId`

源代碼

11.撤銷重作

原理:在 `onChange` 事件到時候,把 `value` 推入 `past` 數組,在撤銷的時候把 `value` 推入 `feature` 數組
複製代碼

源代碼:useHistory hook

## 12. 待續未完
複製代碼
相關文章
相關標籤/搜索