前端使用 Konva 实现可视化设计器(19)- 连接线 - 直线、折线
本章响应小伙伴的反馈,除了算法自动画连接线(仍需优化完善),实现了可以手动绘制直线、折线连接线功能。
请大家动动小手,给我一个免费的 Star 吧~
大家如果发现了 Bug,欢迎来提 Issue 哟~
模式切换
前置工作
连接线 模式种类
// src/Render/types.ts
export enum LinkType {
'auto' = 'auto',
'straight' = 'straight', // 直线
'manual' = 'manual' // 手动折线
}
连接线 模式状态
// src/Render/draws/LinkDraw.ts
// 连接线(临时)
export interface LinkDrawState {
// 略
linkType: Types.LinkType // 连接线类型
linkManualing: boolean // 是否 正在操作拐点
}
连接线 模式切换方法
// src/Render/draws/LinkDraw.ts
/**
* 修改当前连接线类型
* @param linkType Types.LinkType
*/
changeLinkType(linkType: Types.LinkType) {
this.state.linkType = linkType
this.render.config?.on?.linkTypeChange?.(this.state.linkType)
}
连接线 模式切换按钮
<!-- src/App.vue -->
<button @click="onLinkTypeChange(Types.LinkType.auto)"
:disabled="currentLinkType === Types.LinkType.auto">连接线:自动</button>
<button @click="onLinkTypeChange(Types.LinkType.straight)"
:disabled="currentLinkType === Types.LinkType.straight">连接线:直线</button>
<button @click="onLinkTypeChange(Types.LinkType.manual)"
:disabled="currentLinkType === Types.LinkType.manual">连接线:手动</button>
连接线 模式切换事件
// src/App.vue
const currentLinkType = ref(Types.LinkType.auto)
function onLinkTypeChange(linkType: Types.LinkType) {
(render?.draws[Draws.LinkDraw.name] as Draws.LinkDraw).changeLinkType(linkType)
}
当前 连接对(pair) 记录当前 连接线 模式
// src/Render/draws/LinkDraw.ts
export class LinkDraw extends Types.BaseDraw implements Types.Draw {
// 略
override draw() {
// 略
// 连接点
for (const point of points) {
// 略
// 非 选择中
if (group && !group.getAttr('selected')) {
// 略
const anchor = this.render.layer.findOne(`#${point.id}`)
if (anchor) {
// 略
circle.on('mouseup', () => {
if (this.state.linkingLine) {
// 略
// 不同连接点
if (line.circle.id() !== circle.id()) {
// 略
if (toGroup) {
// 略
if (fromPoint) {
// 略
if (toPoint) {
if (Array.isArray(fromPoint.pairs)) {
fromPoint.pairs = [
...fromPoint.pairs,
{
// 略
linkType: this.state.linkType // 记录 连接线 类型
}
]
}
// 略
}
}
}
}
// 略
}
})
// 略
}
}
}
}
}
直线
绘制直线相对简单,通过判断 连接对(pair)记录的 连接线 模式,从起点绘制一条 Line 到终点即可:
// src/Render/draws/LinkDraw.ts
export class LinkDraw extends Types.BaseDraw implements Types.Draw {
// 略
override draw() {
// 略
// 连接线
for (const pair of pairs) {
if (pair.linkType === Types.LinkType.manual) {
// 略,手动折线
} else if (pair.linkType === Types.LinkType.straight) {
// 直线
if (fromGroup && toGroup && fromPoint && toPoint) {
const fromAnchor = fromGroup.findOne(`#${fromPoint.id}`)
const toAnchor = toGroup.findOne(`#${toPoint.id}`)
// 锚点信息
const fromAnchorPos = this.getAnchorPos(fromAnchor)
const toAnchorPos = this.getAnchorPos(toAnchor)
const linkLine = new Konva.Line({
name: 'link-line',
// 用于删除连接线
groupId: fromGroup.id(),
pointId: fromPoint.id,
pairId: pair.id,
linkType: pair.linkType,
points: _.flatten([
[
this.render.toStageValue(fromAnchorPos.x),
this.render.toStageValue(fromAnchorPos.y)
],
[this.render.toStageValue(toAnchorPos.x), this.render.toStageValue(toAnchorPos.y)]
]),
stroke: 'red',
strokeWidth: 2
})
this.group.add(linkLine)
}
} else {
// 略,原算法画连接线逻辑
}
}
}
}
折线
绘制折线,先人为定义 3 种“点”: 1、连接点,就是原来就有的。 2、拐点(待拐),蓝色的,从未拖动过的,一旦拖动,会新增拐点记录。 3、拐点(已拐),绿色的,已经拖动过的,依然可以拖动,但不会新增拐点记录。
请留意下方代码的注释,关键:
- fromGroup 会记录 拐点 manualPoints。
- 连接线 的绘制是从 起点 -> 拐点(们)-> 终点(linkPoints)。
- 拐点正在拖动时,绘制临时的虚线 Line。
- 分别处理 拐点(待拐)和 拐点(已拐)两种情况。
处理 拐点(待拐)和 拐点(已拐)主要区别是:
- 处理 拐点(待拐),遍历 linkPoints 的时候,是成对遍历的。
- 处理 拐点(已拐),遍历 linkPoints 的时候,是跳过 起点 和 终点 的。
- 拖动 拐点(待拐),会新增拐点记录。
- 拖动 拐点(已拐),不会新增拐点记录。
// src/Render/draws/LinkDraw.ts
export class LinkDraw extends Types.BaseDraw implements Types.Draw {
// 略
override draw() {
// 略
// 连接线
for (const pair of pairs) {
if (pair.linkType === Types.LinkType.manual) {
// 手动折线
if (fromGroup && toGroup && fromPoint && toPoint) {
const fromAnchor = fromGroup.findOne(`#${fromPoint.id}`)
const toAnchor = toGroup.findOne(`#${toPoint.id}`)
// 锚点信息
const fromAnchorPos = this.getAnchorPos(fromAnchor)
const toAnchorPos = this.getAnchorPos(toAnchor)
// 拐点(已拐)记录
const manualPoints: Array<{ x: number; y: number }> = Array.isArray(
fromGroup.getAttr('manualPoints')
)
? fromGroup.getAttr('manualPoints')
: []
// 连接点 + 拐点
const linkPoints = [
[
this.render.toStageValue(fromAnchorPos.x),
this.render.toStageValue(fromAnchorPos.y)
],
...manualPoints.map((o) => [o.x, o.y]),
[this.render.toStageValue(toAnchorPos.x), this.render.toStageValue(toAnchorPos.y)]
]
// 连接线
const linkLine = new Konva.Line({
name: 'link-line',
// 用于删除连接线
groupId: fromGroup.id(),
pointId: fromPoint.id,
pairId: pair.id,
linkType: pair.linkType,
points: _.flatten(linkPoints),
stroke: 'red',
strokeWidth: 2
})
this.group.add(linkLine)
// 正在拖动效果
const manualingLine = new Konva.Line({
stroke: '#ff0000',
strokeWidth: 2,
points: [],
dash: [4, 4]
})
this.group.add(manualingLine)
// 拐点
// 拐点(待拐)
for (let i = 0; i < linkPoints.length - 1; i++) {
const circle = new Konva.Circle({
id: nanoid(),
pairId: pair.id,
x: (linkPoints[i][0] + linkPoints[i + 1][0]) / 2,
y: (linkPoints[i][1] + linkPoints[i + 1][1]) / 2,
radius: this.render.toStageValue(this.render.bgSize / 2),
stroke: 'rgba(0,0,255,0.1)',
strokeWidth: this.render.toStageValue(1),
name: 'link-manual-point',
// opacity: 0,
linkManualIndex: i // 当前拐点位置
})
// hover 效果
circle.on('mouseenter', () => {
circle.stroke('rgba(0,0,255,0.8)')
document.body.style.cursor = 'pointer'
})
circle.on('mouseleave', () => {
if (!circle.attrs.dragStart) {
circle.stroke('rgba(0,0,255,0.1)')
document.body.style.cursor = 'default'
}
})
// 拐点操作
circle.on('mousedown', () => {
const pos = circle.getAbsolutePosition()
// 记录操作开始状态
circle.setAttrs({
// 开始坐标
dragStartX: pos.x,
dragStartY: pos.y,
// 正在操作
dragStart: true
})
// 标记状态 - 正在操作拐点
this.state.linkManualing = true
})
this.render.stage.on('mousemove', () => {
if (circle.attrs.dragStart) {
// 正在操作
const pos = this.render.stage.getPointerPosition()
if (pos) {
// 磁贴
const { pos: transformerPos } = this.render.attractTool.attract({
x: pos.x,
y: pos.y,
width: 1,
height: 1
})
// 移动拐点
circle.setAbsolutePosition(transformerPos)
// 正在拖动效果
const tempPoints = [...linkPoints]
tempPoints.splice(circle.attrs.linkManualIndex + 1, 0, [
this.render.toStageValue(transformerPos.x - stageState.x),
this.render.toStageValue(transformerPos.y - stageState.y)
])
manualingLine.points(_.flatten(tempPoints))
}
}
})
circle.on('mouseup', () => {
const pos = circle.getAbsolutePosition()
if (
Math.abs(pos.x - circle.attrs.dragStartX) > this.option.size ||
Math.abs(pos.y - circle.attrs.dragStartY) > this.option.size
) {
// 操作移动距离达到阈值
// stage 状态
const stageState = this.render.getStageState()
// 记录(插入)拐点
manualPoints.splice(circle.attrs.linkManualIndex, 0, {
x: this.render.toStageValue(pos.x - stageState.x),
y: this.render.toStageValue(pos.y - stageState.y)
})
fromGroup.setAttr('manualPoints', manualPoints)
}
// 操作结束
circle.setAttrs({
dragStart: false
})
// state 操作结束
this.state.linkManualing = false
// 销毁
circle.destroy()
manualingLine.destroy()
// 更新历史
this.render.updateHistory()
// 重绘
this.render.redraw()
})
this.group.add(circle)
}
// 拐点(已拐)
for (let i = 1; i < linkPoints.length - 1; i++) {
const circle = new Konva.Circle({
id: nanoid(),
pairId: pair.id,
x: linkPoints[i][0],
y: linkPoints[i][1],
radius: this.render.toStageValue(this.render.bgSize / 2),
stroke: 'rgba(0,100,0,0.1)',
strokeWidth: this.render.toStageValue(1),
name: 'link-manual-point',
// opacity: 0,
linkManualIndex: i // 当前拐点位置
})
// hover 效果
circle.on('mouseenter', () => {
circle.stroke('rgba(0,100,0,1)')
document.body.style.cursor = 'pointer'
})
circle.on('mouseleave', () => {
if (!circle.attrs.dragStart) {
circle.stroke('rgba(0,100,0,0.1)')
document.body.style.cursor = 'default'
}
})
// 拐点操作
circle.on('mousedown', () => {
const pos = circle.getAbsolutePosition()
// 记录操作开始状态
circle.setAttrs({
dragStartX: pos.x,
dragStartY: pos.y,
dragStart: true
})
// 标记状态 - 正在操作拐点
this.state.linkManualing = true
})
this.render.stage.on('mousemove', () => {
if (circle.attrs.dragStart) {
// 正在操作
const pos = this.render.stage.getPointerPosition()
if (pos) {
// 磁贴
const { pos: transformerPos } = this.render.attractTool.attract({
x: pos.x,
y: pos.y,
width: 1,
height: 1
})
// 移动拐点
circle.setAbsolutePosition(transformerPos)
// 正在拖动效果
const tempPoints = [...linkPoints]
tempPoints[circle.attrs.linkManualIndex] = [
this.render.toStageValue(transformerPos.x - stageState.x),
this.render.toStageValue(transformerPos.y - stageState.y)
]
manualingLine.points(_.flatten(tempPoints))
}
}
})
circle.on('mouseup', () => {
const pos = circle.getAbsolutePosition()
if (
Math.abs(pos.x - circle.attrs.dragStartX) > this.option.size ||
Math.abs(pos.y - circle.attrs.dragStartY) > this.option.size
) {
// 操作移动距离达到阈值
// stage 状态
const stageState = this.render.getStageState()
// 记录(更新)拐点
manualPoints[circle.attrs.linkManualIndex - 1] = {
x: this.render.toStageValue(pos.x - stageState.x),
y: this.render.toStageValue(pos.y - stageState.y)
}
fromGroup.setAttr('manualPoints', manualPoints)
}
// 操作结束
circle.setAttrs({
dragStart: false
})
// state 操作结束
this.state.linkManualing = false
// 销毁
circle.destroy()
manualingLine.destroy()
// 更新历史
this.render.updateHistory()
// 重绘
this.render.redraw()
})
this.group.add(circle)
}
}
} else if (pair.linkType === Types.LinkType.straight) {
// 略,直线
} else {
// 略,原算法画连接线逻辑
}
}
}
}
最后,关于 linkManualing 状态,会用在 2 个地方,避免和其它交互产生冲突:
// src/Render/handlers/DragHandlers.ts
// 略
export class DragHandlers implements Types.Handler {
// 略
handlers = {
stage: {
mousedown: (e: Konva.KonvaEventObject<GlobalEventHandlersEventMap['mousedown']>) => {
// 拐点操作中,防止异常拖动
if (!(this.render.draws[Draws.LinkDraw.name] as Draws.LinkDraw).state.linkManualing) {
// 略
}
},
// 略
}
}
}
// src/Render/tools/LinkTool.ts
// 略
export class LinkTool {
// 略
pointsVisible(visible: boolean, group?: Konva.Group) {
// 略
// 拐点操作中,此处不重绘
if (!(this.render.draws[Draws.LinkDraw.name] as Draws.LinkDraw).state.linkManualing) {
// 重绘
this.render.redraw()
}
}
// 略
}
Done!
More Stars please!勾勾手指~