前面,本示例實現了折線連線線,簡述了實現的思路和原理,也已知了一些缺陷。本章將處理一些缺陷的同時,實現支援連線點的自定義,一個節點可以定義多個連線點,最終可以滿足類似圖元接線的效果。
請大家動動小手,給我一個免費的 Star 吧~
大家如果發現了 Bug,歡迎來提 Issue 喲~
github原始碼
gitee原始碼
示例地址
一些調整
- 把示例素材從 src 轉移至 public 目錄,拖入畫布的素材改為非同步載入
- 移除部分示例素材
- 一些開發過程中的測試用例可以線上載入
此前有些朋友說匯入、匯出有異常,估計是線上版本和線下版本的構建示例素材的檔案 hash 字尾不一樣,跨環境匯入、匯出無法載入圖片導致的。現在調整後就應該正常了。
自定義連線點
先說明一下定義:
// src/Render/types.ts
export interface AssetInfoPoint {
x: number
y: number
direction?: 'top' | 'bottom' | 'left' | 'right' // 人為定義連線點屬於元素的什麼方向
}
export interface AssetInfo {
url: string
points?: Array<AssetInfoPoint>
}
// src/Render/draws/LinkDraw.ts
// 連線點
export interface LinkDrawPoint {
id: string
groupId: string
visible: boolean
pairs: LinkDrawPair[]
x: number
y: number
direction?: 'top' | 'bottom' | 'left' | 'right' // 人為定義連線點屬於元素的什麼方向
}
一個素材除了原來的 url 資訊外,增加了一個 points 的連線點陣列,每個 point 除了記錄了它的相對於素材的位置 x、y,還有方向的定義,目的是說明該連線點出入口方向,例如:
做這個定義的原因是,連線方向不可以預知,是與圖元的含義有關。
不設定 direction 的話,就代表連線線可以從上下左右4個方向進出,如:
最佳實踐應該另外實現一個連線點定義工具(也許後面有機會實現一個),多多支援~
// src/App.vue
// 從 public 載入靜態資源 + 自定義連線點
const assetsModules: Array<Types.AssetInfo> = [
{ "url": "./img/svg/ARRESTER_1.svg", points: [{ x: 101, y: 1, direction: 'top' }, { x: 101, y: 199, direction: 'bottom' }] },
{ "url": "./img/svg/ARRESTER_2.svg", points: [{ x: 101, y: 1, direction: 'top' }, { x: 101, y: 199, direction: 'bottom' }] },
{ "url": "./img/svg/ARRESTER_2_1.svg", points: [{ x: 101, y: 1, direction: 'top' }, { x: 101, y: 199, direction: 'bottom' }] },
{ "url": "./img/svg/BREAKER_CLOSE.svg", points: [{ x: 100, y: 1, direction: 'top' }, { x: 100, y: 199, direction: 'bottom' }] },
{ "url": "./img/svg/BREAKER_OPEN.svg", points: [{ x: 100, y: 1, direction: 'top' }, { x: 100, y: 199, direction: 'bottom' }] },
// 略
]
素材拖入之前,需要攜帶 points 資訊:
// src/App.vue
function onDragstart(e: GlobalEventHandlersEventMap['dragstart'], item: Types.AssetInfo) {
if (e.dataTransfer) {
e.dataTransfer.setData('src', item.url)
e.dataTransfer.setData('points', JSON.stringify(item.points)) // 傳遞連線點資訊
e.dataTransfer.setData('type', item.url.match(/([^./]+)\.([^./]+)$/)?.[2] ?? '')
}
}
拖入之後,需要解析 points 資訊:
// src/Render/handlers/DragOutsideHandlers.ts
drop: (e: GlobalEventHandlersEventMap['drop']) => {
const src = e.dataTransfer?.getData('src')
// 接收連線點資訊
let morePoints: Types.AssetInfoPoint[] = []
const morePointsTxt = e.dataTransfer?.getData('points') ?? '[]'
try {
morePoints = JSON.parse(morePointsTxt)
} catch (e) {
console.error(e)
}
// 略
// 預設連線點
let points: Types.AssetInfoPoint[] = [
// 左
{ x: 0, y: group.height() / 2, direction: 'left' },
// 右
{
x: group.width(),
y: group.height() / 2,
direction: 'right'
},
// 上
{ x: group.width() / 2, y: 0, direction: 'top' },
// 下
{
x: group.width() / 2,
y: group.height(),
direction: 'bottom'
}
]
// 自定義連線點 覆蓋 預設連線點
if (Array.isArray(morePoints) && morePoints.length > 0) {
points = morePoints
}
// 連線點資訊
group.setAttrs({
points: points.map(
(o) =>
({
...o,
id: nanoid(),
groupId: group.id(),
visible: false,
pairs: [],
direction: o.direction // 補充資訊
}) as LinkDrawPoint
)
})
// 連線點(錨點)
for (const point of group.getAttr('points') ?? []) {
group.add(
new Konva.Circle({
name: 'link-anchor',
id: point.id,
x: point.x,
y: point.y,
radius: this.render.toStageValue(1),
stroke: 'rgba(0,0,255,1)',
strokeWidth: this.render.toStageValue(2),
visible: false,
direction: point.direction // 補充資訊
})
)
}
// 略
}
如果沒有自定義連線點,這裡會給予之前一樣的 4 個預設連線點。
出入口修改
原來的邏輯就不能用了,需要重寫一個。目標是計算出:沿著當前連線點的方向 與 不可透過區域其中一邊的相交點,上圖:
關注的就是這個綠色點(出入口):
就算這個點,用的是三角函式:
這裡邊長稱為 offset,角度為 rotate,計算大概如下:
const offset = gap * Math.atan(((90 - rotate) * Math.PI) / 180)
不同角度範圍,計算略有不同,是根據多次測試得出的,有興趣的朋友可以在最佳化精簡一下。
完整方法有點長,四個角直接賦值,其餘按不同角度範圍計算:
// 連線出入口(原來第二個引數是 最小區域,先改為 不可透過區域)
getEntry(anchor: Konva.Node, groupForbiddenArea: Area, gap: number): Konva.Vector2d {
// stage 狀態
const stageState = this.render.getStageState()
const fromPos = anchor.absolutePosition()
// 預設為 起點/終點 位置(無 direction 時的值)
let x = fromPos.x - stageState.x,
y = fromPos.y - stageState.y
const direction = anchor.attrs.direction
// 定義了 direction 的時候
if (direction) {
// 取整 連線點 錨點 旋轉角度(保留 1 位小數點)
const rotate = Math.round(anchor.getAbsoluteRotation() * 10) / 10
// 利用三角函式,計算按 direction 方向與 不可透過區域 的相交點位置(即出/入口 entry)
if (rotate === -45) {
if (direction === 'top') {
x = groupForbiddenArea.x1
y = groupForbiddenArea.y1
} else if (direction === 'bottom') {
x = groupForbiddenArea.x2
y = groupForbiddenArea.y2
} else if (direction === 'left') {
x = groupForbiddenArea.x1
y = groupForbiddenArea.y2
} else if (direction === 'right') {
x = groupForbiddenArea.x2
y = groupForbiddenArea.y1
}
} else if (rotate === 45) {
if (direction === 'top') {
x = groupForbiddenArea.x2
y = groupForbiddenArea.y1
} else if (direction === 'bottom') {
x = groupForbiddenArea.x1
y = groupForbiddenArea.y2
} else if (direction === 'left') {
x = groupForbiddenArea.x1
y = groupForbiddenArea.y1
} else if (direction === 'right') {
x = groupForbiddenArea.x2
y = groupForbiddenArea.y2
}
} else if (rotate === 135) {
if (direction === 'top') {
x = groupForbiddenArea.x2
y = groupForbiddenArea.y2
} else if (direction === 'bottom') {
x = groupForbiddenArea.x1
y = groupForbiddenArea.y1
} else if (direction === 'left') {
x = groupForbiddenArea.x2
y = groupForbiddenArea.y1
} else if (direction === 'right') {
x = groupForbiddenArea.x1
y = groupForbiddenArea.y2
}
} else if (rotate === -135) {
if (direction === 'top') {
x = groupForbiddenArea.x1
y = groupForbiddenArea.y2
} else if (direction === 'bottom') {
x = groupForbiddenArea.x2
y = groupForbiddenArea.y1
} else if (direction === 'left') {
x = groupForbiddenArea.x2
y = groupForbiddenArea.y2
} else if (direction === 'right') {
x = groupForbiddenArea.x1
y = groupForbiddenArea.y1
}
} else if (rotate > -45 && rotate < 45) {
const offset = gap * Math.tan((rotate * Math.PI) / 180)
if (direction === 'top') {
x = fromPos.x - stageState.x + offset
y = groupForbiddenArea.y1
} else if (direction === 'bottom') {
x = fromPos.x - stageState.x - offset
y = groupForbiddenArea.y2
} else if (direction === 'left') {
x = groupForbiddenArea.x1
y = fromPos.y - stageState.y - offset
} else if (direction === 'right') {
x = groupForbiddenArea.x2
y = fromPos.y - stageState.y + offset
}
} else if (rotate > 45 && rotate < 135) {
const offset = gap * Math.atan(((90 - rotate) * Math.PI) / 180)
if (direction === 'top') {
x = groupForbiddenArea.x2
y = fromPos.y - stageState.y - offset
} else if (direction === 'bottom') {
x = groupForbiddenArea.x1
y = fromPos.y - stageState.y + offset
} else if (direction === 'left') {
x = fromPos.x - stageState.x - offset
y = groupForbiddenArea.y1
} else if (direction === 'right') {
x = fromPos.x - stageState.x + offset
y = groupForbiddenArea.y2
}
} else if ((rotate > 135 && rotate <= 180) || (rotate >= -180 && rotate < -135)) {
const offset = gap * Math.tan((rotate * Math.PI) / 180)
if (direction === 'top') {
x = fromPos.x - stageState.x - offset
y = groupForbiddenArea.y2
} else if (direction === 'bottom') {
x = fromPos.x - stageState.x + offset
y = groupForbiddenArea.y1
} else if (direction === 'left') {
x = groupForbiddenArea.x2
y = fromPos.y - stageState.y + offset
} else if (direction === 'right') {
x = groupForbiddenArea.x1
y = fromPos.y - stageState.y - offset
}
} else if (rotate > -135 && rotate < -45) {
const offset = gap * Math.atan(((90 + rotate) * Math.PI) / 180)
if (direction === 'top') {
x = groupForbiddenArea.x1
y = fromPos.y - stageState.y - offset
} else if (direction === 'bottom') {
x = groupForbiddenArea.x2
y = fromPos.y - stageState.y + offset
} else if (direction === 'left') {
x = fromPos.x - stageState.x - offset
y = groupForbiddenArea.y2
} else if (direction === 'right') {
x = fromPos.x - stageState.x + offset
y = groupForbiddenArea.y1
}
}
}
return { x, y } as Konva.Vector2d
}
原來的演算法起點、終點 與 連線點一一對應,科室現在新的計算方法得出的出入口x、y座標與連線點不再總是存在同一方向一致(因為被旋轉),所以現在把演算法的起點、終點改為出入口對應:
// 出口、入口 -> 演算法 起點、終點
if (columns[x] === fromEntry.x && rows[y] === fromEntry.y) {
matrix[y][x] = 1
matrixStart = { x, y }
}
if (columns[x] === toEntry.x && rows[y] === toEntry.y) {
matrix[y][x] = 1
matrixEnd = { x, y }
}
上面提到沒有定義 direction 的連線點可以從不同方向出入,所以會進行下面處理:
// 沒有定義方向(給於十字可透過區域)
// 如,從:
// 1 1 1
// 1 0 1
// 1 1 1
// 變成:
// 1 0 1
// 0 0 0
// 1 0 1
if (!fromAnchor.attrs.direction) {
if (columns[x] === fromEntry.x || rows[y] === fromEntry.y) {
if (
x >= columnFromStart &&
x <= columnFromEnd &&
y >= rowFromStart &&
y <= rowFromEnd
) {
matrix[y][x] = 1
}
}
}
if (!toAnchor.attrs.direction) {
if (columns[x] === toEntry.x || rows[y] === toEntry.y) {
if (x >= columnToStart && x <= columnToEnd && y >= rowToStart && y <= rowToEnd) {
matrix[y][x] = 1
}
}
}
最後在繪製連線的時候,補上連線點(起點、終點)即可:
this.group.add(
new Konva.Line({
name: 'link-line',
// 用於刪除連線線
groupId: fromGroup.id(),
pointId: fromPoint.id,
pairId: pair.id,
//
points: _.flatten([
[
this.render.toStageValue(fromAnchorPos.x),
this.render.toStageValue(fromAnchorPos.y)
], // 補充 起點
...way.map((o) => [
this.render.toStageValue(columns[o.x]),
this.render.toStageValue(rows[o.y])
]),
[this.render.toStageValue(toAnchorPos.x), this.render.toStageValue(toAnchorPos.y)] // 補充 終點
]),
stroke: 'red',
strokeWidth: 2
})
)
測試一下
已知缺陷
從 Issue 中得知,當節點進行說 transform rotate 旋轉的時候,對齊就會出問題。大家多多支援,後面抽空研究處理一下(-_-)。。。
More Stars please!勾勾手指~
原始碼
gitee原始碼
示例地址