前端使用 Konva 实现可视化设计器(21)- 绘制图形(椭圆)
本章开始补充一些基础的图形绘制,比如绘制:直线、曲线、圆/椭形、矩形。这一章主要分享一下本示例是如何开始绘制一个图形的,并以绘制圆/椭形为实现目标。
请大家动动小手,给我一个免费的 Star 吧~
大家如果发现了 Bug,欢迎来提 Issue 哟~
接下来主要说说:
- UI
- Graph(图形)
- canvas2svg 打补丁
- 拐点旋转修复
UI - 图形绘制类型切换
先找几个图标,增加按钮,分别代表绘制图形:直线、曲线、圆/椭形、矩形:
选中图形类型后,即可通过拖动绘制图形(绘制完成后,清空选择):
定义图形类型:
// src/Render/types.ts
/**
* 图形类型
*/
export enum GraphType {
Line = 'Line', // 直线
Curve = 'Curve', // 曲线
Rect = 'Rect', // 矩形
Circle = 'Circle' // 圆/椭圆形
}
在 Render 中记录当前图形类型,并提供修改方法与事件:
// src/Render/index.ts
// 略
// 画图类型
graphType: Types.GraphType | undefined = undefined
// 略
// 改变画图类型
changeGraphType(type?: Types.GraphType) {
this.graphType = type
this.emit('graph-type-change', this.graphType)
}
工具栏按钮通讯:
// src/components/main-header/index.vue
// 略
const emit = defineEmits([/* 略 */, 'update:graphType'])
const props = withDefaults(defineProps<{
// 略
graphType?: Types.GraphType
}>(), {
// 略
});
// 略
watch(() => props.render, () => {
if (props.render) {
// 略
props.render?.on('graph-type-change', (value) => {
emit('update:graphType', value)
})
}
}, {
immediate: true
})
// 略
function onGraph(type: Types.GraphType) {
emit('update:graphType', props.graphType === type ? undefined : type)
以上就是绘制图形的工具栏入口。
Graph - 图形定义及其相关实现
相关代码文件:
1、src/Render/graphs/BaseGraph.ts - 抽象类:定义通用属性、逻辑、外部接口定义。
2、src/Render/graphs/Circle.ts 继承 BaseGraph - 构造 圆/椭形 ;处理创建部分交互信息;关键逻辑的实现。
3、src/Render/handlers/GraphHandlers.ts - 收集图形创建所需交互信息,接着交给 Circle 静态处理方法处理。
4、src/Render/draws/GraphDraw.ts - 绘制图形、调整点 - 绘制 调整点 的锚点;收集并处理交互信息,接着并交给 Circle 静态处理方法处理。
BaseGraph 抽象类
// src/Render/graphs/BaseGraph.ts
// 略
/**
* 图形类
* 实例主要用于新建图形时,含新建同时的大小拖动。
* 静态方法主要用于新建之后,通过 调整点 调整的逻辑定义
*/
export abstract class BaseGraph {
/**
* 更新 图形 的 调整点 的 锚点位置
* @param width 图形 的 宽度
* @param height 图形 的 高度
* @param rotate 图形 的 旋转角度
* @param anchorShadows 图形 的 调整点 的 锚点
*/
static updateAnchorShadows(
width: number,
height: number,
rotate: number,
anchorShadows: Konva.Circle[]
) {
console.log('请实现 updateAnchorShadows', width, height, anchorShadows)
}
/**
* 更新 图形 的 连接点 的 锚点位置
* @param width 图形 的 宽度
* @param height 图形 的 高度
* @param rotate 图形 的 旋转角度
* @param anchors 图形 的 调整点 的 锚点
*/
static updateLinkAnchorShadows(
width: number,
height: number,
rotate: number,
linkAnchorShadows: Konva.Circle[]
) {
console.log('请实现 updateLinkAnchorShadows', width, height, linkAnchorShadows)
}
/**
* 生成 调整点
* @param render 渲染实例
* @param graph 图形
* @param anchor 调整点 定义
* @param anchorShadow 调整点 锚点
* @param adjustingId 正在操作的 调整点 id
* @returns
*/
static createAnchorShape(
render: Render,
graph: Konva.Group,
anchor: Types.GraphAnchor,
anchorShadow: Konva.Circle,
adjustType: string,
adjustGroupId: string
): Konva.Shape {
console.log('请实现 createAnchorShape', render, graph, anchor, anchorShadow, adjustingId, adjustGroupId)
return new Konva.Shape()
}
/**
* 调整 图形
* @param render 渲染实例
* @param graph 图形
* @param graphSnap 图形 的 备份
* @param rect 当前 调整点
* @param rects 所有 调整点
* @param startPoint 鼠标按下位置
* @param endPoint 鼠标拖动位置
*/
static adjust(
render: Render,
graph: Konva.Group,
graphSnap: Konva.Group,
rect: Types.GraphAnchorShape,
rects: Types.GraphAnchorShape[],
startPoint: Konva.Vector2d,
endPoint: Konva.Vector2d
) {
console.log('请实现 updateAnchorShadows', render, graph, rect, startPoint, endPoint)
}
//
protected render: Render
group: Konva.Group
id: string // 就是 group 的id
/**
* 鼠标按下位置
*/
protected dropPoint: Konva.Vector2d = { x: 0, y: 0 }
/**
* 调整点 定义
*/
protected anchors: Types.GraphAnchor[] = []
/**
* 调整点 的 锚点
*/
protected anchorShadows: Konva.Circle[] = []
/**
* 调整点 定义
*/
protected linkAnchors: Types.LinkDrawPoint[] = []
/**
* 连接点 的 锚点
*/
protected linkAnchorShadows: Konva.Circle[] = []
constructor(
render: Render,
dropPoint: Konva.Vector2d,
config: {
anchors: Types.GraphAnchor[]
linkAnchors: Types.AssetInfoPoint[]
}
) {
this.render = render
this.dropPoint = dropPoint
this.id = nanoid()
this.group = new Konva.Group({
id: this.id,
name: 'asset',
assetType: Types.AssetType.Graph
})
// 调整点 定义
this.anchors = config.anchors.map((o) => ({
...o,
// 补充信息
name: 'anchor',
groupId: this.group.id()
}))
// 记录在 group 中
this.group.setAttr('anchors', this.anchors)
// 新建 调整点 的 锚点
for (const anchor of this.anchors) {
const circle = new Konva.Circle({
adjustType: anchor.adjustType,
name: anchor.name,
radius: 0
// radius: this.render.toStageValue(1),
// fill: 'red'
})
this.anchorShadows.push(circle)
this.group.add(circle)
}
// 连接点 定义
this.linkAnchors = config.linkAnchors.map(
(o) =>
({
...o,
id: nanoid(),
groupId: this.group.id(),
visible: false,
pairs: [],
direction: o.direction,
alias: o.alias
}) as Types.LinkDrawPoint
)
// 连接点信息
this.group.setAttrs({
points: this.linkAnchors
})
// 新建 连接点 的 锚点
for (const point of this.linkAnchors) {
const circle = 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,
alias: point.alias
})
this.linkAnchorShadows.push(circle)
this.group.add(circle)
}
this.group.on('mouseenter', () => {
// 显示 连接点
this.render.linkTool.pointsVisible(true, this.group)
})
this.group.on('mouseleave', () => {
// 隐藏 连接点
this.render.linkTool.pointsVisible(false, this.group)
// 隐藏 hover 框
this.group.findOne('#hoverRect')?.visible(false)
})
this.render.layer.add(this.group)
this.render.redraw()
}
/**
* 调整进行时
* @param point 鼠标位置 相对位置
*/
abstract drawMove(point: Konva.Vector2d): void
/**
* 调整结束
*/
abstract drawEnd(): void
}
这里的:
- 静态方法,相当定义了绘制图形必要的工具方法,具体实现交给具体的图形类定义;
- 接着是绘制图形必要的属性及其初始化;
- 最后,抽象方法约束了图形实例必要的方法。
绘制 圆/椭形
图形是可以调整的,这里 圆/椭形 拥有 8 个 调整点:
还要考虑图形被旋转后,依然能合理调整:
调整本身也是支持磁贴的:
图形也支持 连接点:
图形类 - Circle
// src/Render/graphs/Circle.ts
// 略
/**
* 图形 圆/椭圆
*/
export class Circle extends BaseGraph {
// 实现:更新 图形 的 调整点 的 锚点位置
static override updateAnchorShadows(
width: number,
height: number,
rotate: number,
anchorShadows: Konva.Circle[]
): void {
for (const shadow of anchorShadows) {
switch (shadow.attrs.id) {
case 'top':
shadow.position({
x: width / 2,
y: 0
})
break
case 'bottom':
shadow.position({
x: width / 2,
y: height
})
break
case 'left':
shadow.position({
x: 0,
y: height / 2
})
break
case 'right':
shadow.position({
x: width,
y: height / 2
})
break
case 'top-left':
shadow.position({
x: 0,
y: 0
})
break
case 'top-right':
shadow.position({
x: width,
y: 0
})
break
case 'bottom-left':
shadow.position({
x: 0,
y: height
})
break
case 'bottom-right':
shadow.position({
x: width,
y: height
})
break
}
}
}
// 实现:更新 图形 的 连接点 的 锚点位置
static override updateLinkAnchorShadows(
width: number,
height: number,
rotate: number,
linkAnchorShadows: Konva.Circle[]
): void {
for (const shadow of linkAnchorShadows) {
switch (shadow.attrs.alias) {
case 'top':
shadow.position({
x: width / 2,
y: 0
})
break
case 'bottom':
shadow.position({
x: width / 2,
y: height
})
break
case 'left':
shadow.position({
x: 0,
y: height / 2
})
break
case 'right':
shadow.position({
x: width,
y: height / 2
})
break
case 'center':
shadow.position({
x: width / 2,
y: height / 2
})
break
}
}
}
// 实现:生成 调整点
static createAnchorShape(
render: Types.Render,
graph: Konva.Group,
anchor: Types.GraphAnchor,
anchorShadow: Konva.Circle,
adjustType: string,
adjustGroupId: string
): Konva.Shape {
// stage 状态
const stageState = render.getStageState()
const x = render.toStageValue(anchorShadow.getAbsolutePosition().x - stageState.x),
y = render.toStageValue(anchorShadow.getAbsolutePosition().y - stageState.y)
const offset = render.pointSize + 5
const shape = new Konva.Line({
name: 'anchor',
anchor: anchor,
//
// stroke: colorMap[anchor.id] ?? 'rgba(0,0,255,0.2)',
stroke:
adjustType === anchor.adjustType && graph.id() === adjustGroupId
? 'rgba(0,0,255,0.8)'
: 'rgba(0,0,255,0.2)',
strokeWidth: render.toStageValue(2),
// 位置
x,
y,
// 路径
points:
{
'top-left': _.flatten([
[-offset, offset / 2],
[-offset, -offset],
[offset / 2, -offset]
]),
top: _.flatten([
[-offset, -offset],
[offset, -offset]
]),
'top-right': _.flatten([
[-offset / 2, -offset],
[offset, -offset],
[offset, offset / 2]
]),
right: _.flatten([
[offset, -offset],
[offset, offset]
]),
'bottom-right': _.flatten([
[-offset / 2, offset],
[offset, offset],
[offset, -offset / 2]
]),
bottom: _.flatten([
[-offset, offset],
[offset, offset]
]),
'bottom-left': _.flatten([
[-offset, -offset / 2],
[-offset, offset],
[offset / 2, offset]
]),
left: _.flatten([
[-offset, -offset],
[-offset, offset]
])
}[anchor.id] ?? [],
// 旋转角度
rotation: graph.getAbsoluteRotation()
})
shape.on('mouseenter', () => {
shape.stroke('rgba(0,0,255,0.8)')
document.body.style.cursor = 'move'
})
shape.on('mouseleave', () => {
shape.stroke(shape.attrs.adjusting ? 'rgba(0,0,255,0.8)' : 'rgba(0,0,255,0.2)')
document.body.style.cursor = shape.attrs.adjusting ? 'move' : 'default'
})
return shape
}
// 实现:调整 图形
static override adjust(
render: Types.Render,
graph: Konva.Group,
graphSnap: Konva.Group,
shapeRecord: Types.GraphAnchorShape,
shapeRecords: Types.GraphAnchorShape[],
startPoint: Konva.Vector2d,
endPoint: Konva.Vector2d
) {
// 目标 圆/椭圆
const circle = graph.findOne('.graph') as Konva.Ellipse
// 镜像
const circleSnap = graphSnap.findOne('.graph') as Konva.Ellipse
// 调整点 锚点
const anchors = (graph.find('.anchor') ?? []) as Konva.Circle[]
// 连接点 锚点
const linkAnchors = (graph.find('.link-anchor') ?? []) as Konva.Circle[]
const { shape: adjustShape } = shapeRecord
if (circle && circleSnap) {
let [graphWidth, graphHeight] = [graph.width(), graph.height()]
const [graphRotation, anchorId, ex, ey] = [
Math.round(graph.rotation()),
adjustShape.attrs.anchor?.id,
endPoint.x,
endPoint.y
]
let anchorShadow: Konva.Circle | undefined, anchorShadowAcross: Konva.Circle | undefined
switch (anchorId) {
case 'top':
{
anchorShadow = graphSnap.findOne(`#top`)
anchorShadowAcross = graphSnap.findOne(`#bottom`)
}
break
case 'bottom':
{
anchorShadow = graphSnap.findOne(`#bottom`)
anchorShadowAcross = graphSnap.findOne(`#top`)
}
break
case 'left':
{
anchorShadow = graphSnap.findOne(`#left`)
anchorShadowAcross = graphSnap.findOne(`#right`)
}
break
case 'right':
{
anchorShadow = graphSnap.findOne(`#right`)
anchorShadowAcross = graphSnap.findOne(`#left`)
}
break
case 'top-left':
{
anchorShadow = graphSnap.findOne(`#top-left`)
anchorShadowAcross = graphSnap.findOne(`#bottom-right`)
}
break
case 'top-right':
{
anchorShadow = graphSnap.findOne(`#top-right`)
anchorShadowAcross = graphSnap.findOne(`#bottom-left`)
}
break
case 'bottom-left':
{
anchorShadow = graphSnap.findOne(`#bottom-left`)
anchorShadowAcross = graphSnap.findOne(`#top-right`)
}
break
case 'bottom-right':
{
anchorShadow = graphSnap.findOne(`#bottom-right`)
anchorShadowAcross = graphSnap.findOne(`#top-left`)
}
break
}
if (anchorShadow && anchorShadowAcross) {
const { x: sx, y: sy } = anchorShadow.getAbsolutePosition()
const { x: ax, y: ay } = anchorShadowAcross.getAbsolutePosition()
// anchorShadow:它是当前操作的 调整点 锚点
// anchorShadowAcross:它是当前操作的 调整点 反方向对面的 锚点
// 调整大小
{
// 略
// 计算比较复杂,不一定是最优方案,详情请看工程代码。
// 基本逻辑:
// 1、通过鼠标移动,计算当前鼠标位置、当前操作的 调整点 锚点 位置(原位置) 分别与 anchorShadowAcross(原位置)的距离;
// 2、 保持 anchorShadowAcross 位置固定,通过上面两距离的变化比例,计算最新的宽高大小;
// 3、期间要约束不同角度不同方向的宽高处理,有的只改变宽、有的只改变高、有的同时改变宽和高。
}
// 调整位置
{
// 略
// 计算比较复杂,不一定是最优方案,详情请看工程代码。
// 基本逻辑:
// 利用三角函数,通过最新的宽高,调整图形的坐标。
}
}
// 更新 圆/椭圆 大小
circle.x(graphWidth / 2)
circle.radiusX(graphWidth / 2)
circle.y(graphHeight / 2)
circle.radiusY(graphHeight / 2)
// 更新 调整点 的 锚点 位置
Circle.updateAnchorShadows(graphWidth, graphHeight, graphRotation, anchors)
// 更新 图形 的 连接点 的 锚点位置
Circle.updateLinkAnchorShadows(graphWidth, graphHeight, graphRotation, linkAnchors)
// stage 状态
const stageState = render.getStageState()
// 更新 调整点 位置
for (const anchor of anchors) {
for (const { shape } of shapeRecords) {
if (shape.attrs.anchor?.adjustType === anchor.attrs.adjustType) {
const anchorShadow = graph.findOne(`#${anchor.attrs.id}`)
if (anchorShadow) {
shape.position({
x: render.toStageValue(anchorShadow.getAbsolutePosition().x - stageState.x),
y: render.toStageValue(anchorShadow.getAbsolutePosition().y - stageState.y)
})
shape.rotation(graph.getAbsoluteRotation())
}
}
}
}
}
}
/**
* 默认图形大小
*/
static size = 100
/**
* 圆/椭圆 对应的 Konva 实例
*/
private circle: Konva.Ellipse
constructor(render: Types.Render, dropPoint: Konva.Vector2d) {
super(render, dropPoint, {
// 定义了 8 个 调整点
anchors: [
{ adjustType: 'top' },
{ adjustType: 'bottom' },
{ adjustType: 'left' },
{ adjustType: 'right' },
{ adjustType: 'top-left' },
{ adjustType: 'top-right' },
{ adjustType: 'bottom-left' },
{ adjustType: 'bottom-right' }
].map((o) => ({
adjustType: o.adjustType, // 调整点 类型定义
type: Types.GraphType.Circle // 记录所属 图形
})),
linkAnchors: [
{ x: 0, y: 0, alias: 'top', direction: 'top' },
{ x: 0, y: 0, alias: 'bottom', direction: 'bottom' },
{ x: 0, y: 0, alias: 'left', direction: 'left' },
{ x: 0, y: 0, alias: 'right', direction: 'right' },
{ x: 0, y: 0, alias: 'center' }
] as Types.AssetInfoPoint[]
})
// 新建 圆/椭圆
this.circle = new Konva.Ellipse({
name: 'graph',
x: 0,
y: 0,
radiusX: 0,
radiusY: 0,
stroke: 'black',
strokeWidth: 1
})
// 加入
this.group.add(this.circle)
// 鼠标按下位置 作为起点
this.group.position(this.dropPoint)
}
// 实现:拖动进行时
override drawMove(point: Konva.Vector2d): void {
// 鼠标拖动偏移量
let offsetX = point.x - this.dropPoint.x,
offsetY = point.y - this.dropPoint.y
// 确保不翻转
if (offsetX < 1) {
offsetX = 1
}
if (offsetY < 1) {
offsetY = 1
}
// 半径
const radiusX = offsetX / 2,
radiusY = offsetY / 2
// 圆/椭圆 位置大小
this.circle.x(radiusX)
this.circle.y(radiusY)
this.circle.radiusX(radiusX)
this.circle.radiusY(radiusY)
// group 大小
this.group.size({
width: offsetX,
height: offsetY
})
// 更新 图形 的 调整点 的 锚点位置
Circle.updateAnchorShadows(offsetX, offsetY, 1, this.anchorShadows)
// 更新 图形 的 连接点 的 锚点位置
Circle.updateLinkAnchorShadows(offsetX, offsetY, 1, this.linkAnchorShadows)
// 重绘
this.render.redraw([Draws.GraphDraw.name, Draws.LinkDraw.name])
}
// 实现:拖动结束
override drawEnd(): void {
if (this.circle.radiusX() <= 1 && this.circle.radiusY() <= 1) {
// 加入只点击,无拖动
// 默认大小
const width = Circle.size,
height = width
const radiusX = Circle.size / 2,
radiusY = radiusX
// 圆/椭圆 位置大小
this.circle.x(radiusX)
this.circle.y(radiusY)
this.circle.radiusX(radiusX - this.circle.strokeWidth())
this.circle.radiusY(radiusY - this.circle.strokeWidth())
// group 大小
this.group.size({
width,
height
})
// 更新 图形 的 调整点 的 锚点位置
Circle.updateAnchorShadows(width, height, 1, this.anchorShadows)
// 更新 图形 的 连接点 的 锚点位置
Circle.updateLinkAnchorShadows(width, height, 1, this.linkAnchorShadows)
// 对齐线清除
this.render.attractTool.alignLinesClear()
// 重绘
this.render.redraw([Draws.GraphDraw.name, Draws.LinkDraw.name])
}
}
}
GraphHandlers
// src/Render/handlers/GraphHandlers.ts
// 略
export class GraphHandlers implements Types.Handler {
// 略
/**
* 新建图形中
*/
graphing = false
/**
* 当前新建图形类型
*/
currentGraph: Graphs.BaseGraph | undefined
/**
* 获取鼠标位置,并处理为 相对大小
* @param attract 含磁贴计算
* @returns
*/
getStagePoint(attract = false) {
const pos = this.render.stage.getPointerPosition()
if (pos) {
const stageState = this.render.getStageState()
if (attract) {
// 磁贴
const { pos: transformerPos } = this.render.attractTool.attractPoint(pos)
return {
x: this.render.toStageValue(transformerPos.x - stageState.x),
y: this.render.toStageValue(transformerPos.y - stageState.y)
}
} else {
return {
x: this.render.toStageValue(pos.x - stageState.x),
y: this.render.toStageValue(pos.y - stageState.y)
}
}
}
return null
}
handlers = {
stage: {
mousedown: (e: Konva.KonvaEventObject<GlobalEventHandlersEventMap['mousedown']>) => {
if (this.render.graphType) {
// 选中图形类型,开始
if (e.target === this.render.stage) {
this.graphing = true
this.render.selectionTool.selectingClear()
const point = this.getStagePoint()
if (point) {
if (this.render.graphType === Types.GraphType.Circle) {
// 新建 圆/椭圆 实例
this.currentGraph = new Graphs.Circle(this.render, point)
}
}
}
}
},
mousemove: () => {
if (this.graphing) {
if (this.currentGraph) {
const pos = this.getStagePoint(true)
if (pos) {
// 新建并马上调整图形
this.currentGraph.drawMove(pos)
}
}
}
},
mouseup: () => {
if (this.graphing) {
if (this.currentGraph) {
// 调整结束
this.currentGraph.drawEnd()
}
// 调整结束
this.graphing = false
// 清空图形类型选择
this.render.changeGraphType()
// 对齐线清除
this.render.attractTool.alignLinesClear()
// 重绘
this.render.redraw([Draws.GraphDraw.name, Draws.LinkDraw.name])
}
}
}
}
}
GraphDraw
// src/Render/draws/GraphDraw.ts
// 略
export interface GraphDrawState {
/**
* 调整中
*/
adjusting: boolean
/**
* 调整中 id
*/
adjustType: string
}
// 略
export class GraphDraw extends Types.BaseDraw implements Types.Draw {
// 略
state: GraphDrawState = {
adjusting: false,
adjustType: ''
}
/**
* 鼠标按下 调整点 位置
*/
startPoint: Konva.Vector2d = { x: 0, y: 0 }
/**
* 图形 group 镜像
*/
graphSnap: Konva.Group | undefined
constructor(render: Types.Render, layer: Konva.Layer, option: GraphDrawOption) {
super(render, layer)
this.option = option
this.group.name(this.constructor.name)
}
/**
* 获取鼠标位置,并处理为 相对大小
* @param attract 含磁贴计算
* @returns
*/
getStagePoint(attract = false) {
const pos = this.render.stage.getPointerPosition()
if (pos) {
const stageState = this.render.getStageState()
if (attract) {
// 磁贴
const { pos: transformerPos } = this.render.attractTool.attractPoint(pos)
return {
x: this.render.toStageValue(transformerPos.x - stageState.x),
y: this.render.toStageValue(transformerPos.y - stageState.y)
}
} else {
return {
x: this.render.toStageValue(pos.x - stageState.x),
y: this.render.toStageValue(pos.y - stageState.y)
}
}
}
return null
}
// 调整 预处理、定位静态方法
adjusts(
shapeDetailList: {
graph: Konva.Group
shapeRecords: { shape: Konva.Shape; anchorShadow: Konva.Circle }[]
}[]
) {
for (const { shapeRecords, graph } of shapeDetailList) {
for (const { shape } of shapeRecords) {
shape.setAttr('adjusting', false)
}
for (const shapeRecord of shapeRecords) {
const { shape } = shapeRecord
// 鼠标按下
shape.on('mousedown', () => {
this.state.adjusting = true
this.state.adjustType = shape.attrs.anchor?.adjustType
this.state.adjustGroupId = graph.id()
shape.setAttr('adjusting', true)
const pos = this.getStagePoint()
if (pos) {
this.startPoint = pos
// 图形 group 镜像,用于计算位置、大小的偏移
this.graphSnap = graph.clone()
}
})
// 调整中
this.render.stage.on('mousemove', () => {
if (this.state.adjusting && this.graphSnap) {
if (shape.attrs.anchor?.type === Types.GraphType.Circle) {
// 调整 圆/椭圆 图形
if (shape.attrs.adjusting) {
const pos = this.getStagePoint(true)
if (pos) {
// 使用 圆/椭圆 静态处理方法
Graphs.Circle.adjust(
this.render,
graph,
this.graphSnap,
shapeRecord,
shapeRecords,
this.startPoint,
pos
)
// 重绘
this.render.redraw([Draws.GraphDraw.name, Draws.LinkDraw.name])
}
}
}
}
})
// 调整结束
this.render.stage.on('mouseup', () => {
this.state.adjusting = false
this.state.adjustType = ''
this.state.adjustGroupId = ''
// 恢复显示所有 调整点
for (const { shape } of shapeRecords) {
shape.opacity(1)
shape.setAttr('adjusting', false)
shape.stroke('rgba(0,0,255,0.2)')
document.body.style.cursor = 'default'
}
// 销毁 镜像
this.graphSnap?.destroy()
// 对齐线清除
this.render.attractTool.alignLinesClear()
})
this.group.add(shape)
}
}
}
override draw() {
this.clear()
// 所有图形
const graphs = this.render.layer
.find('.asset')
.filter((o) => o.attrs.assetType === Types.AssetType.Graph) as Konva.Group[]
const shapeDetailList: {
graph: Konva.Group
shapeRecords: { shape: Konva.Shape; anchorShadow: Konva.Circle }[]
}[] = []
for (const graph of graphs) {
// 非选中状态才显示 调整点
if (!graph.attrs.selected) {
const anchors = (graph.attrs.anchors ?? []) as Types.GraphAnchor[]
const shapeRecords: { shape: Konva.Shape; anchorShadow: Konva.Circle }[] = []
// 根据 调整点 信息,创建
for (const anchor of anchors) {
// 调整点 的显示 依赖其隐藏的 锚点 位置、大小等信息
const anchorShadow = graph.findOne(`#${anchor.id}`) as Konva.Circle
if (anchorShadow) {
const shape = Graphs.Circle.createAnchorShape(
this.render,
graph,
anchor,
anchorShadow,
this.state.adjustingId,
this.state.adjustGroupId
)
shapeRecords.push({ shape, anchorShadow })
}
}
shapeDetailList.push({
graph,
shapeRecords
})
}
}
this.adjusts(shapeDetailList)
}
}
稍显臃肿,后面慢慢优化吧 -_-
canvas2svg 打补丁
上面已经实现了绘制图形(圆/椭形),但是导出 svg 的时候报错了。经过错误定位以及源码阅读,发现:
1、当 Konva.Group 包含 Konva.Ellipse 的时候,无法导出 svg 文件
2、对 Konva.Ellipse 调整如 radiusX、radiusY 属性时,无法正确输出 path 路径
1、canvas2svg 尝试给 g 节点赋予 path 属性,导致异常报错。
现通过 hack __applyCurrentDefaultPath 方法,增加处理 nodeName === 'g' 的场景
2、查看 Konva.Ellipse.prototype._sceneFunc 方法源码,Konva 绘制 Ellipse 是通过 canvas 的 arc + scale 实现的,对应代码注释 A。
实际效果,无法仿照 canvas 的平均 scale,会出现 stroke 粗细不一。
因此,尝试通过识别 scale 修改 path 特征,修复此问题。
// src/Render/tools/ImportExportTool.ts
C2S.prototype.__applyCurrentDefaultPath = function () {
// 补丁:修复以下问题:
// 1、当 Konva.Group 包含 Konva.Ellipse 的时候,无法导出 svg 文件
// 2、对 Konva.Ellipse 调整如 radiusX、radiusY 属性时,无法正确输出 path 路径
//
// PS:
// 1、canvas2svg 尝试给 g 节点赋予 path 属性,导致异常报错。
// 现通过 hack __applyCurrentDefaultPath 方法,增加处理 nodeName === 'g' 的场景
//
// 2、查看 Konva.Ellipse.prototype._sceneFunc 方法源码,
// Konva 绘制 Ellipse 是通过 canvas 的 arc + scale 实现的,对应代码注释 A,
// 实际效果,无法仿照 canvas 的平均 scale,会出现 stroke 粗细不一。
// 因此,尝试通过识别 scale 修改 path 特征,修复此问题。
//
// (以上 hack 仅针对示例绘制 图形 时的特征进行处理,并未深入研究 canvas2svg 为何会进入错误的逻辑)
if (this.__currentElement.nodeName === 'g') {
const g = this.__currentElement.querySelector('g')
if (g) {
// 注释 A
// const d = this.__currentDefaultPath
// const path = document.createElementNS('http://www.w3.org/2000/svg', 'path') as SVGElement
// path.setAttribute('d', d)
// path.setAttribute('fill', 'none')
// g.append(path)
const scale = g.getAttribute('transform')
if (scale) {
const match = scale.match(/scale\(([^),]+),([^)]+)\)/)
if (match) {
const [sx, sy] = [parseFloat(match[1]), parseFloat(match[2])]
let d = this.__currentDefaultPath
const reg = /A ([^ ]+) ([^ ]+) /
const match2 = d.match(reg)
if (match2) {
const [rx, ry] = [parseFloat(match2[1]), parseFloat(match2[2])]
d = d.replace(reg, `A ${rx * sx} ${ry * sy} `)
const path = document.createElementNS(
'http://www.w3.org/2000/svg',
'path'
) as SVGElement
path.setAttribute('d', d)
path.setAttribute('fill', 'none')
this.__currentElement.append(path)
}
}
} else {
const d = this.__currentDefaultPath
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path') as SVGElement
path.setAttribute('d', d)
path.setAttribute('fill', 'none')
this.__currentElement.append(path)
}
}
console.warn(
'[Hacked] Attempted to apply path command to node ' + this.__currentElement.nodeName
)
return
}
// 原逻辑
if (this.__currentElement.nodeName === 'path') {
const d = this.__currentDefaultPath
this.__currentElement.setAttribute('d', d)
} else {
throw new Error('Attempted to apply path command to node ' + this.__currentElement.nodeName)
}
}
以上 hack 仅针对示例绘制 图形 时的特征进行处理,并未深入研究 canvas2svg 为何会进入错误的逻辑
拐点旋转修复
测试发现,连接线 的 拐点 并没有能跟随旋转角度调整坐标,因此补充一个修复:
// src/Render/handlers/SelectionHandlers.ts
// 略
/**
* 矩阵变换:坐标系中的一个点,围绕着另外一个点进行旋转
* - - - - - - - -
* |x`| |cos -sin| |x-a| |a|
* | | = | | | | +
* |y`| |sin cos| |y-b| |b|
* - - - - - - - -
* @param x 目标节点坐标 x
* @param y 目标节点坐标 y
* @param centerX 围绕的点坐标 x
* @param centerY 围绕的点坐标 y
* @param angle 旋转角度
* @returns
*/
rotatePoint(x: number, y: number, centerX: number, centerY: number, angle: number) {
// 将角度转换为弧度
const radians = (angle * Math.PI) / 180
// 计算旋转后的坐标
const newX = Math.cos(radians) * (x - centerX) - Math.sin(radians) * (y - centerY) + centerX
const newY = Math.sin(radians) * (x - centerX) + Math.cos(radians) * (y - centerY) + centerY
return { x: newX, y: newY }
}
lastRotation = 0
// 略
handlers = {
// 略
transformer: {
transform: () => {
// 旋转时,拐点也要跟着动
const back = this.render.transformer.findOne('.back')
if (back) {
// stage 状态
const stageState = this.render.getStageState()
const { x, y, width, height } = back.getClientRect()
const rotation = back.getAbsoluteRotation() - this.lastRotation
const centerX = x + width / 2
const centerY = y + height / 2
const groups = this.render.transformer.nodes()
const points = groups.reduce((ps, group) => {
return ps.concat(Array.isArray(group.getAttr('points')) ? group.getAttr('points') : [])
}, [] as Types.LinkDrawPoint[])
const pairs = points.reduce((ps, point) => {
return ps.concat(point.pairs ? point.pairs.filter((o) => !o.disabled) : [])
}, [] as Types.LinkDrawPair[])
for (const pair of pairs) {
const fromGroup = groups.find((o) => o.id() === pair.from.groupId)
const toGroup = groups.find((o) => o.id() === pair.to.groupId)
// 必须成对移动才记录
if (fromGroup && toGroup) {
// 移动
if (fromGroup.attrs.manualPointsMap && fromGroup.attrs.manualPointsMapBefore) {
let manualPoints = fromGroup.attrs.manualPointsMap[pair.id]
const manualPointsBefore = fromGroup.attrs.manualPointsMapBefore[pair.id]
if (Array.isArray(manualPoints) && Array.isArray(manualPointsBefore)) {
manualPoints = manualPointsBefore.map((o: Types.ManualPoint) => {
const { x, y } = this.rotatePoint(
this.render.toBoardValue(o.x) + stageState.x,
this.render.toBoardValue(o.y) + stageState.y,
centerX,
centerY,
rotation
)
return {
x: this.render.toStageValue(x - stageState.x),
y: this.render.toStageValue(y - stageState.y)
}
})
fromGroup.setAttr('manualPointsMap', {
...fromGroup.attrs.manualPointsMap,
[pair.id]: manualPoints
})
}
}
}
}
}
// 重绘
this.render.redraw([Draws.GraphDraw.name, Draws.LinkDraw.name, Draws.PreviewDraw.name])
}
}
// 略
}
Thanks watching~
More Stars please!勾勾手指~