本章分享一下如何使用 Konva 繪製基礎圖形:矩形、直線、折線,希望大家繼續關注和支援哈!
請大家動動小手,給我一個免費的 Star 吧~
大家如果發現了 Bug,歡迎來提 Issue 喲~
github原始碼
gitee原始碼
示例地址
矩形
先上效果!
實現方式基本和《前端使用 Konva 實現視覺化設計器(21)- 繪製圖形(橢圓)》是一致的,主要區別矩形的大小和橢圓形的大小設定方式不一樣,特別是矩形無需設定 offset。其它就不再贅述了哈。
直線、折線
先上效果!
簡單描述一下上面的互動:
首先,繪製一條直線,淡出畫一條直線還是比較簡單的,根據記錄滑鼠按下的位置和滑鼠釋放的位置,就很容易得到 Konva.Line 的 points 應該設定的值了。
然後,沿用繪製 橢圓形、矩形 的思路,它只有特定的 2 個“調整點”,分別代表 起點 和 終點。
// src/Render/graphs/Line.ts
// 略
/**
* 直線、折線
*/
export class Line extends BaseGraph {
// 略
constructor(render: Types.Render, dropPoint: Konva.Vector2d) {
super(render, dropPoint, {
type: Types.GraphType.Line,
// 定義了 2 個 調整點
anchors: [{ adjustType: 'start' }, { adjustType: 'end' }].map((o) => ({
adjustType: o.adjustType // 調整點 型別定義
})),
linkAnchors: [
{ x: 0, y: 0, alias: 'start' },
{ x: 0, y: 0, alias: 'end' }
] as Types.AssetInfoPoint[]
})
// 新建 直線、折線
this.line = new Konva.Line({
name: 'graph',
x: 0,
y: 0,
stroke: 'black',
strokeWidth: 1,
hitStrokeWidth: render.toStageValue(5)
})
// 給予 1 畫素,防止匯出圖片 toDataURL 失敗
this.group.size({
width: 1,
height: 1
})
// 加入
this.group.add(this.line)
// 滑鼠按下位置 作為起點
this.group.position(this.dropPoint)
}
// 實現:拖動進行時
override drawMove(point: Konva.Vector2d): void {
// 滑鼠拖動偏移量
const offsetX = point.x - this.dropPoint.x,
offsetY = point.y - this.dropPoint.y
// 起點、終點
const linkPoints = [
[this.line.x(), this.line.y()],
[this.line.x() + offsetX, this.line.y() + offsetY]
]
// 直線、折線 路徑
this.line.points(_.flatten(linkPoints))
// 更新 圖形 的 調整點 的 錨點位置
Line.updateAnchorShadows(this.group, this.anchorShadows, this.line)
// 更新 圖形 的 連線點 的 錨點位置
Line.updateLinkAnchorShadows(this.group, this.linkAnchorShadows, this.line)
// 重繪
this.render.redraw([Draws.GraphDraw.name, Draws.LinkDraw.name, Draws.PreviewDraw.name])
}
// 實現:拖動結束
override drawEnd(): void {
if (this.line.width() <= 1 && this.line.height() <= 1) {
// 加入只點選,無拖動
// 預設大小
const width = Line.size,
height = width
// 起點、終點
const linkPoints = [
[this.line.x(), this.line.y()],
[this.line.x() + width, this.line.y() + height]
]
// 直線、折線 位置大小
this.line.points(_.flatten(linkPoints))
}
// 更新 調整點(拐點)
Line.updateAnchor(this.render, this.group)
// 更新 圖形 的 調整點 的 錨點位置
Line.updateAnchorShadows(this.group, this.anchorShadows, this.line)
// 更新 圖形 的 連線點 的 錨點位置
Line.updateLinkAnchorShadows(this.group, this.linkAnchorShadows, this.line)
// 對齊線清除
this.render.attractTool.alignLinesClear()
// 更新歷史
this.render.updateHistory()
// 重繪
this.render.redraw([Draws.GraphDraw.name, Draws.LinkDraw.name, Draws.PreviewDraw.name])
}
// 略
}
調整點,可以改變 直線、折線 的 起點、終點。
// 略
/**
* 直線、折線
*/
export class Line extends BaseGraph {
// 實現:更新 圖形 的 調整點 的 錨點位置
static override updateAnchorShadows(
graph: Konva.Group,
anchorShadows: Konva.Circle[],
shape?: Konva.Line
): void {
if (shape) {
const points = shape.points()
//
for (const shadow of anchorShadows) {
switch (shadow.attrs.adjustType) {
case 'start':
shadow.position({
x: points[0],
y: points[1]
})
break
case 'end':
shadow.position({
x: points[points.length - 2],
y: points[points.length - 1]
})
break
}
}
}
}
// 略
// 實現:生成 調整點
static override createAnchorShapes(
render: Types.Render,
graph: Konva.Group,
anchorAndShadows: {
anchor: Types.GraphAnchor
anchorShadow: Konva.Circle
shape?: Konva.Shape
}[],
adjustAnchor?: Types.GraphAnchor
): {
anchorAndShadows: {
anchor: Types.GraphAnchor
anchorShadow: Konva.Circle
shape?: Konva.Shape | undefined
}[]
} {
// stage 狀態
const stageState = render.getStageState()
const graphShape = graph.findOne('.graph') as Konva.Line
if (graphShape) {
const points = graphShape.points()
for (const anchorAndShadow of anchorAndShadows) {
let rotate = 0
const { anchor, anchorShadow } = anchorAndShadow
const x = render.toStageValue(anchorShadow.getAbsolutePosition().x - stageState.x),
y = render.toStageValue(anchorShadow.getAbsolutePosition().y - stageState.y)
if (anchor.adjustType === 'manual') {
// 略
} else {
if (anchor.adjustType === 'start') {
rotate = Line.calculateAngle(points[2] - points[0], points[3] - points[1])
} else if (anchor.adjustType === 'end') {
rotate = Line.calculateAngle(
points[points.length - 2] - points[points.length - 4],
points[points.length - 1] - points[points.length - 3]
)
}
const cos = Math.cos((rotate * Math.PI) / 180)
const sin = Math.sin((rotate * Math.PI) / 180)
const offset = render.toStageValue(render.pointSize + 5)
const offsetX = offset * sin
const offsetY = offset * cos
const anchorShape = new Konva.Circle({
name: 'anchor',
anchor: anchor,
//
fill:
adjustAnchor?.adjustType === anchor.adjustType && adjustAnchor?.groupId === graph.id()
? 'rgba(0,0,255,0.8)'
: 'rgba(0,0,255,0.2)',
radius: render.toStageValue(3),
strokeWidth: 0,
// 位置
x: x,
y: y,
offsetX:
anchor.adjustType === 'start' ? offsetX : anchor.adjustType === 'end' ? -offsetX : 0,
offsetY:
anchor.adjustType === 'start' ? offsetY : anchor.adjustType === 'end' ? -offsetY : 0,
// 旋轉角度
rotation: graph.getAbsoluteRotation()
})
anchorShape.on('mouseenter', () => {
anchorShape.fill('rgba(0,0,255,0.8)')
document.body.style.cursor = 'move'
})
anchorShape.on('mouseleave', () => {
anchorShape.fill(
anchorShape.attrs.adjusting ? 'rgba(0,0,255,0.8)' : 'rgba(0,0,255,0.2)'
)
document.body.style.cursor = anchorShape.attrs.adjusting ? 'move' : 'default'
})
anchorAndShadow.shape = anchorShape
}
}
}
return { anchorAndShadows }
}
// 略
// 實現:調整 圖形
static override adjust(
render: Types.Render,
graph: Konva.Group,
graphSnap: Konva.Group,
adjustShape: Konva.Shape,
anchorAndShadows: {
anchor: Types.GraphAnchor
anchorShadow: Konva.Circle
shape?: Konva.Shape | undefined
}[],
startPoint: Konva.Vector2d,
endPoint: Konva.Vector2d
) {
// 目標 直線、折線
const line = graph.findOne('.graph') as Konva.Line
// 映象
const lineSnap = graphSnap.findOne('.graph') as Konva.Line
// 調整點 錨點
const anchors = (graph.find('.anchor') ?? []) as Konva.Circle[]
// 映象
const anchorsSnap = (graphSnap.find('.anchor') ?? []) as Konva.Circle[]
// 連線點 錨點
const linkAnchors = (graph.find('.link-anchor') ?? []) as Konva.Circle[]
if (line && lineSnap) {
// stage 狀態
const stageState = render.getStageState()
{
const [graphRotation, adjustType, ex, ey] = [
Math.round(graph.rotation()),
adjustShape.attrs.anchor?.adjustType,
endPoint.x,
endPoint.y
]
const { x: cx, y: cy, width: cw, height: ch } = graphSnap.getClientRect()
const { x, y } = graph.position()
const [centerX, centerY] = [cx + cw / 2, cy + ch / 2]
const { x: sx, y: sy } = Line.rotatePoint(ex, ey, centerX, centerY, -graphRotation)
const { x: rx, y: ry } = Line.rotatePoint(x, y, centerX, centerY, -graphRotation)
const points = line.points()
const manualPoints = (line.attrs.manualPoints ?? []) as Types.LineManualPoint[]
if (adjustType === 'manual') {
// 略
} else {
const anchor = anchors.find((o) => o.attrs.adjustType === adjustType)
const anchorShadow = anchorsSnap.find((o) => o.attrs.adjustType === adjustType)
if (anchor && anchorShadow) {
{
const linkPoints = [
[points[0], points[1]],
...manualPoints.sort((a, b) => a.index - b.index).map((o) => [o.x, o.y]),
[points[points.length - 2], points[points.length - 1]]
]
switch (adjustType) {
case 'start':
{
linkPoints[0] = [sx - rx, sy - ry]
line.points(_.flatten(linkPoints))
}
break
case 'end':
{
linkPoints[linkPoints.length - 1] = [sx - rx, sy - ry]
line.points(_.flatten(linkPoints))
}
break
}
}
}
}
}
// 更新 調整點(拐點)
Line.updateAnchor(render, graph)
// 更新 調整點 的 錨點 位置
Line.updateAnchorShadows(graph, anchors, line)
// 更新 圖形 的 連線點 的 錨點位置
Line.updateLinkAnchorShadows(graph, linkAnchors, line)
// 更新 調整點 位置
for (const anchor of anchors) {
for (const { shape } of anchorAndShadows) {
if (shape) {
if (shape.attrs.anchor?.adjustType === anchor.attrs.adjustType) {
const anchorShadow = graph
.find(`.anchor`)
.find((o) => o.attrs.adjustType === anchor.attrs.adjustType)
if (anchorShadow) {
shape.position({
x: render.toStageValue(anchorShadow.getAbsolutePosition().x - stageState.x),
y: render.toStageValue(anchorShadow.getAbsolutePosition().y - stageState.y)
})
shape.rotation(graph.getAbsoluteRotation())
}
}
}
}
}
// 重繪
render.redraw([Draws.GraphDraw.name, Draws.LinkDraw.name, Draws.PreviewDraw.name])
}
}
// 略
}
折線
相比繪製 橢圓形、矩形 比較不一樣的地方在於,橢圓形、矩形 的“調整點”是固定的,而繪製 折線 不一樣,沒調整一個新的拐點,就會新增 2 個新調整點,整體互動與 手動連線線 類似。
// src/Render/draws/GraphDraw.ts
// 略
export interface GraphDrawState {
// 略
/**
* 調整中 調整點
*/
adjustAnchor?: Types.GraphAnchor
/**
* 滑鼠按下 調整點 位置
*/
startPointCurrent: Konva.Vector2d
/**
* 圖形 group
*/
graphCurrent?: Konva.Group
/**
* 圖形 group 映象,用於計算位置、大小的偏移
*/
graphCurrentSnap?: Konva.Group
}
// 略
export class GraphDraw extends Types.BaseDraw implements Types.Draw {
// 略
state: GraphDrawState = {
adjusting: false,
adjustGroupId: '',
startPointCurrent: { x: 0, y: 0 }
}
// 略
override draw() {
this.clear()
// 所有圖形
const graphs = this.render.layer
.find('.asset')
.filter((o) => o.attrs.assetType === Types.AssetType.Graph) as Konva.Group[]
for (const graph of graphs) {
// 非選中狀態才顯示 調整點
if (!graph.attrs.selected) {
// 略
for (const anchorAndShadow of anchorAndShadows) {
const { shape } = anchorAndShadow
if (shape) {
// 滑鼠按下
shape.on('mousedown', () => {
const pos = this.getStagePoint()
if (pos) {
this.state.adjusting = true
this.state.adjustAnchor = shape.attrs.anchor
this.state.adjustGroupId = graph.id()
this.state.startPointCurrent = pos
this.state.graphCurrent = graph
this.state.graphCurrentSnap = graph.clone()
shape.setAttr('adjusting', true)
if (this.state.adjustAnchor) {
switch (shape.attrs.anchor?.type) {
case Types.GraphType.Line:
// 使用 直線、折線 靜態處理方法
Graphs.Line.adjustStart(this.render, graph, this.state.adjustAnchor, pos)
break
}
}
}
})
// 略
// 調整結束
this.render.stage.on('mouseup', () => {
// 略
this.state.adjusting = false
this.state.adjustAnchor = undefined
this.state.adjustGroupId = ''
// 恢復顯示所有 調整點
for (const { shape } of anchorAndShadows) {
if (shape) {
shape.opacity(1)
shape.setAttr('adjusting', false)
if (shape.attrs.anchor?.type === Types.GraphType.Line) {
if (shape.attrs.anchor.adjusted) {
shape.fill('rgba(0,0,0,0.4)')
} else {
shape.fill('rgba(0,0,255,0.2)')
}
} else {
shape.stroke('rgba(0,0,255,0.2)')
}
}
// 略
}
// 略
})
// 略
}
}
}
}
}
}
上面除了需要更多的狀態記錄 調整 資訊,還需要定義 Line 特有的 adjustStart 方法:
// src/Render/graphs/Line.ts
// 略
/**
* 直線、折線
*/
export class Line extends BaseGraph {
// 略
/**
* 調整之前
*/
static adjustStart(
render: Types.Render,
graph: Konva.Group,
adjustAnchor: Types.GraphAnchor & { manualIndex?: number; adjusted?: boolean },
endPoint: Konva.Vector2d
) {
const { x: gx, y: gy } = graph.position()
const shape = graph.findOne('.graph') as Konva.Line
if (shape && typeof adjustAnchor.manualIndex === 'number') {
const manualPoints = (shape.attrs.manualPoints ?? []) as Types.LineManualPoint[]
if (adjustAnchor.adjusted) {
//
} else {
manualPoints.push({
x: endPoint.x - gx,
y: endPoint.y - gy,
index: adjustAnchor.manualIndex
})
shape.setAttr('manualPoints', manualPoints)
}
// 更新 調整點(拐點)
Line.updateAnchor(render, graph)
}
}
}
// 略
動態的調整點,會記錄在 line 的 attrs 中 manualPoints,每次首次調整一處 拐點,就會新增一個 新 拐點,主要應用在:
// 略
/**
* 直線、折線
*/
export class Line extends BaseGraph {
// 略
// 實現:調整 圖形
static override adjust(
render: Types.Render,
graph: Konva.Group,
graphSnap: Konva.Group,
adjustShape: Konva.Shape,
anchorAndShadows: {
anchor: Types.GraphAnchor
anchorShadow: Konva.Circle
shape?: Konva.Shape | undefined
}[],
startPoint: Konva.Vector2d,
endPoint: Konva.Vector2d
) {
// 目標 直線、折線
const line = graph.findOne('.graph') as Konva.Line
// 映象
const lineSnap = graphSnap.findOne('.graph') as Konva.Line
// 調整點 錨點
const anchors = (graph.find('.anchor') ?? []) as Konva.Circle[]
// 映象
const anchorsSnap = (graphSnap.find('.anchor') ?? []) as Konva.Circle[]
// 連線點 錨點
const linkAnchors = (graph.find('.link-anchor') ?? []) as Konva.Circle[]
if (line && lineSnap) {
// stage 狀態
const stageState = render.getStageState()
{
const [graphRotation, adjustType, ex, ey] = [
Math.round(graph.rotation()),
adjustShape.attrs.anchor?.adjustType,
endPoint.x,
endPoint.y
]
const { x: cx, y: cy, width: cw, height: ch } = graphSnap.getClientRect()
const { x, y } = graph.position()
const [centerX, centerY] = [cx + cw / 2, cy + ch / 2]
const { x: sx, y: sy } = Line.rotatePoint(ex, ey, centerX, centerY, -graphRotation)
const { x: rx, y: ry } = Line.rotatePoint(x, y, centerX, centerY, -graphRotation)
const points = line.points()
const manualPoints = (line.attrs.manualPoints ?? []) as Types.LineManualPoint[]
if (adjustType === 'manual') {
if (adjustShape.attrs.anchor?.manualIndex !== void 0) {
const index = adjustShape.attrs.anchor?.adjusted
? adjustShape.attrs.anchor?.manualIndex
: adjustShape.attrs.anchor?.manualIndex + 1
const manualPointIndex = manualPoints.findIndex((o) => o.index === index)
if (manualPointIndex > -1) {
manualPoints[manualPointIndex].x = sx - rx
manualPoints[manualPointIndex].y = sy - ry
}
const linkPoints = [
[points[0], points[1]],
...manualPoints.sort((a, b) => a.index - b.index).map((o) => [o.x, o.y]),
[points[points.length - 2], points[points.length - 1]]
]
line.setAttr('manualPoints', manualPoints)
line.points(_.flatten(linkPoints))
//
const adjustAnchorShadow = anchors.find(
(o) => o.attrs.adjustType === 'manual' && o.attrs.manualIndex === index
)
if (adjustAnchorShadow) {
adjustAnchorShadow.position({
x: sx - rx,
y: sy - ry
})
}
}
} else {
// 略
}
}
// 略
}
}
// 略
/**
* 更新 調整點(拐點)
* @param render
* @param graph
*/
static updateAnchor(render: Types.Render, graph: Konva.Group) {
const anchors = graph.attrs.anchors ?? []
const anchorShadows = graph.find('.anchor') ?? []
const shape = graph.findOne('.graph') as Konva.Line
if (shape) {
// 已拐
let manualPoints = (shape.attrs.manualPoints ?? []) as Types.LineManualPoint[]
const points = shape.points()
// 調整點 + 拐點
const linkPoints = [
[points[0], points[1]],
...manualPoints.sort((a, b) => a.index - b.index).map((o) => [o.x, o.y]),
[points[points.length - 2], points[points.length - 1]]
]
// 清空 調整點(拐點),保留 start end
anchors.splice(2)
const shadows = anchorShadows.splice(2)
for (const shadow of shadows) {
shadow.remove()
shadow.destroy()
}
manualPoints = []
for (let i = linkPoints.length - 1; i > 0; i--) {
linkPoints.splice(i, 0, [])
}
// 調整點(拐點)
for (let i = 1; i < linkPoints.length - 1; i++) {
const anchor = {
type: graph.attrs.graphType,
adjustType: 'manual',
//
name: 'anchor',
groupId: graph.id(),
//
manualIndex: i,
adjusted: false
}
if (linkPoints[i].length === 0) {
anchor.adjusted = false
// 新增
const prev = linkPoints[i - 1]
const next = linkPoints[i + 1]
const circle = new Konva.Circle({
adjustType: anchor.adjustType,
anchorType: anchor.type,
name: anchor.name,
manualIndex: anchor.manualIndex,
radius: 0,
// radius: render.toStageValue(2),
// fill: 'red',
//
x: (prev[0] + next[0]) / 2,
y: (prev[1] + next[1]) / 2,
anchor
})
graph.add(circle)
} else {
anchor.adjusted = true
// 已拐
const circle = new Konva.Circle({
adjustType: anchor.adjustType,
anchorType: anchor.type,
name: anchor.name,
manualIndex: anchor.manualIndex,
adjusted: true,
radius: 0,
// radius: render.toStageValue(2),
// fill: 'red',
//
x: linkPoints[i][0],
y: linkPoints[i][1],
anchor
})
graph.add(circle)
manualPoints.push({
x: linkPoints[i][0],
y: linkPoints[i][1],
index: anchor.manualIndex
})
}
anchors.push(anchor)
}
shape.setAttr('manualPoints', manualPoints)
graph.setAttr('anchors', anchors)
}
}
// 略
}
上面簡單的說,就是處理 manualPoints 的演算法,負責控制新增拐點,然後把“點”們插入到 起點、終點 之間,最後處理成 Konva.Line 的 points 的值。
順帶一說。區分 起點、終點 和 拐點 是透過 attrs 中的 adjustType 欄位;區分 拐點 是否已經操作過 是透過 attrs 中的 adjusted 欄位;拐點是存在明確的順序的,會記錄在 attrs 的 manualIndex 欄位中。
個人覺得,目前,繪製圖形的 程式碼結構 和 變數命名 容易產生歧義,後面儘量抽出時間重構一下,大家支援支援 👇!
Thanks watching~
More Stars please!勾勾手指~
原始碼
gitee原始碼
示例地址