初入Konva.js,实现一个可拖动比例图该项目是一个用于表示各个部分占比的图表。可以用拖动的交互形式去直观地改变占比关
背景
我需要一个可改变占比关系的比例图,用来表示人们在一定收入水平下各部分需求的占比关系。表示事物占比关系的最好方式是饼图,但没有办法表示超出总体的部分,或者说扩张的部分。用数字来说明的话就是饼图最多只能表示为100%,而我可能需要表示到120%(例如对支出的总需求大于总收入的情况)。所以我决定用一个长方形来作为比例图的总体部分。
Konva.js
Konva.js是一个canvas框架,它拓展了canvas上许多的能力,其中最重要的就是提供了与canvas画布中内容元素交互的能力。
试想一下如果不使用canvas框架,在做一些较为复杂的canvas开发时会面临哪一些难题?
比如实现拖动。
- 首先要求鼠标选中某个元素,但canvas是一整块画布,没有选中元素这个概念。于是就需要监听整个canvas画布上鼠标的点击位置。
- 对想要选中的图形,需要用数学方程y=f(x)来表达纵坐标与横坐标之间的关系。然后通过图形方程与鼠标点击位置(x,y)的距离方程来判断点击位置是否在图形上。
- 拖动时继续监听鼠标位置,不断地记录鼠标的位置变动,然后重绘整张画布。
这会是一个很大的工程量,而有了Konva.js以后,这些繁琐的工程就不再需要我们去做了。它可以非常方便地支持选中元素和拖动。
总而言之,往后的canvas开发,框架必不可少。
基础功能实现
初始布置
npm create vite@latest // demo项目当然选用vanilla
npm install konva
import Konva from "konva"
let stage = new Konva.Stage({
container: 'container', // id of container <div>
width: 800,
height: 500
})
let layer = new Konva.Layer()
stage.add(layer)
layer.draw()
文档节点中需要有一个id对应着Konva的stage的元素
<style>
#container {
margin: 100px;
}
</style>
<body>
<div id="container"></div>
<script type="module" src="/main.js"></script>
</body>
画出长方形表示总体
let rect = new Konva.Rect({
x: 100,
y: 50,
width: 400,
height: 100,
fill: 'green',
stroke: 'black',
strokeWidth: 2
})
layer.add(rect)
画出分割线将长方形分割为4部分
假设以平分的形式分割总体
function createLine(points) {
let line = new Konva.Line({
points: points,
stroke: 'red',
strokeWidth: 2,
draggable: true
})
return line
}
let lines = [] // 用来将line收集起来备用
for (let i = 0; i < 3; i++) {
let line = createLine([
(i + 2) * 100, 0, // 200/300/400
(i + 2) * 100, 150
])
layer.add(line)
lines.push(line)
}
由于创建线段时,设置了draggable=true
的原因,此时的线段是可以直接被拖动的。
为了更好的表达线段被选中拖动,我们在鼠标选中线段时增添一点点样式。并且限制线段在y轴上的移动,只允许其在水平方向上移动。
function createLine(points) {
let line = new Konva.Line({
points: points,
stroke: 'red',
strokeWidth: 2,
draggable: true
})
line.on('mouseenter', function () {
stage.container().style.cursor = 'col-resize';
})
line.on('mouseleave', function () {
stage.container().style.cursor = 'default';
})
line.on('dragmove', () => {
line.y(0) // 限制y轴拖动
stage.container().style.cursor = 'col-resize'
})
return line
}
之后,给线段的移动限定范围,不允许它越过长方形总体和其他的线段。
由于对线段的水平方向上的移动只能通过如line.x(100)
这样设置值的方式来限制,并且这个line.x
所代表的含义是距离该线段初始位置的移动值。例如,当三条线段都向左位移10px,通过对各线段line.x()
所访问到的值均为-10,而不是线段移动后所处的位置,如:200-10=190, 300-10=290, 400-10=390。
基于这个原因,我选择的计算方法为:对于左边界,选择当前线段的初始值减去左边线段的初始值的负数加上左边线段的移动值,即line.prev.x() - (line.original - line.prev.original)
,如果已经是最左边的线段,它的左边没有别的线段了,则它的左边界用零减当前线段的初始值即-line.original
。
对于右边界,我们也遵循同样的思路。
为此,我们需要先记录线段的初始值和线段的前后关系。
let lines = [] // 用来将line收集起来备用
for (let i = 0; i < 3; i++) {
let line = createLine([
(i + 2) * 100, 0, // 200/300/400
(i + 2) * 100, 150
])
line.original = 100 * (i + 1) // 100为线段间隔宽度
layer.add(line)
lines.push(line)
}
// 创建line之间的前后关系
for (let i = 0; i < lines.length; i++) {
if (i > 0) {
lines[i].prev = lines[i - 1]
}
if (i < lines.length - 1) {
lines[i].next = lines[i + 1]
}
}
line.on('dragmove', () => {
line.y(0) // 限制y轴拖动
let leftBound = line.prev
? line.prev.x() - (line.original - line.prev.original)
: -line.original
if (line.x() <= leftBound) {
line.x(leftBound)
return
}
let rightBound = line.next
? line.next.x() + (line.next.original - line.original)
: (rect.width() - line.original)
if (line.x() >= rightBound) {
line.x(rightBound)
return
}
stage.container().style.cursor = 'col-resize'
})
题外话:上面的代码给这些线段创建了一个前后关系,很容易就想到一个数据结构——双向链表。看起来也特别的符合,我有想过用双向链表的形式去实现这些线段。但是变量名如何起呢,通过什么样的变量名来访问它们呢?当没有一个很好的名字称呼它的时候,我就意识到把这些线段视为一个双向链表,可能不是一个好的方式。
给分割块标注文字
function createRatio(text, x) {
let ratio = new Konva.Text({
x,
y: rect.y() + rect.height() / 2, // 长方形的1/2高度 + 长方形在y轴上的偏移
text: text,
})
ratio.offsetX(ratio.width() / 2) // 调整文字中心位置
return ratio
}
let ratios = [] // 用来将ratio收集起来备用
for (let i = 0; i < 4; i++) {
// 文字的x坐标 = 长方形的偏移 + 各个分割块的中点x值
let ratio = createRatio('25%', (i + 1) * 100 + 50) // 150/250/350/450
layer.add(ratio)
ratios.push(ratio)
}
拖动线段,修改文字值与位置
让我们先思考一下文字修改的逻辑,当文字内容需要发生变动时,共有两处需要修改,一个是它的x位置,另一个是它的值。
- 对于x位置,用分割块左边线段的位置+1/2的分割块的宽度+长方形在水平方向上的偏移。
- 对于文字值,用分割块的宽度除以长方形的宽度的百分比
因此,这部分的核心逻辑为:
const blockWidth = rightBound - leftBound // 分割块宽度自然是右边的线段位置减左边线段位置
ratio.x(blockWidth / 2 + leftBound + rect.x())
ratio.text(Math.round(blockWidth / rect.width() * 100) + '%')
将这部分逻辑封装到ratio
的自定义方法中,等到线段被拖动时,通过线段dragmove
事件的回调函数直接调用就可以了。还可以补上一些小优化,即在文字显示位置不够的情况下让它隐藏起来。
function createRatio(text, x) {
let ratio = new Konva.Text({
x,
y: rect.y() + rect.height() / 2, // 长方形的1/2高度 + 长方形在y轴上的偏移
text: text,
})
ratio.offsetX(ratio.width() / 2) // 调整文字水平偏移值,使文字在分割块内居中
ratio.update = function(leftBound = 0, rightBound = rect.width()) {
const blockWidth = rightBound - leftBound
ratio.x(blockWidth / 2 + leftBound + rect.x())
ratio.text(Math.round(blockWidth / rect.width() * 100) + '%')
// 控制文字在边界情况下的显示与隐藏
blockWidth < ratio.width()
? ratio.hide()
: ratio.show()
}
return ratio
}
到这里就完成了文字更新的所需逻辑,之后我们将目光放到对选中线段的拖动逻辑上。增加一个loc
字段用来记录各线段拖动后的位置。
let lines = [] // 用来将line收集起来备用
for (let i = 0; i < 3; i++) {
let line = createLine([
(i + 2) * 100, 0, // 200/300/400
(i + 2) * 100, 150
])
line.original = 100 * (i + 1) // 100为线段间隔宽度
line.loc = 100 * (i + 1) // 记录line当前所处位置,用于计算ratio的值与坐标
layer.add(line)
lines.push(line)
}
别忘了拖动线段时,线段两侧的文字都要修改。因此,需要多写一份逻辑去将ratio
"注册"到对应的线段上去
// 需要放到ratios的声明之后
for (let i = 0; i < lines.length; i++) {
lines[i].leftRatio = ratios[i]
lines[i].rightRatio = ratios[i + 1]
}
完成以上的铺垫,我们只需要在dragmove
事件回调中轻轻地调用所写的逻辑:
line.on('dragmove', () => {
...
line.loc = line.x() + line.original
line.leftRatio.update(line?.prev?.loc, line.loc)
line.rightRatio.update(line.loc, line?.next?.loc)
stage.container().style.cursor = 'col-resize'
})
做到这里是不是感觉大功告成了呢?
绘制超出总体的部分
就如同开头所说,之所以选择长方形而不是饼图作为占比图,是因为还想表达超出总体的部分。对于这一部分,我选择在长方形两侧用一个虚线框来表示。并且在总体两侧增加两个箭头,拖动改变虚线框的大小。
箭头部分其实是用的自定义形状,没有什么好说的,直接对着画。并且也需要对它进行拖动行为的y轴上的限制,与边界上的限制。
function createArrow(startingPoint) {
let sign = startingPoint > 0 ? -1 : 1
let arrow = new Konva.Shape({
sceneFunc: function (context, shape) {
context.beginPath();
context.moveTo(startingPoint + rect.x(), 100);
context.lineTo(startingPoint + sign * 25 + rect.x(), 90);
context.quadraticCurveTo(startingPoint + sign * 20 + rect.x(), 100, startingPoint + sign * 25 + rect.x(), 110);
context.closePath();
context.fillStrokeShape(shape);
},
fill: '#00D2FF',
stroke: 'black',
strokeWidth: 3,
draggable: true,
})
arrow.on('dragmove', () => {
arrow.y(0)
if ((startingPoint > 0 && arrow.x() < 0) || (startingPoint <= 0 && arrow.x() > 0)) {
arrow.x(0)
return
}
})
return arrow
}
let leftArrow = createArrow(-50)
let rightArrow = createArrow(rect.width() + 50)
layer.add(leftArrow)
layer.add(rightArrow)
虚线框的实现是通过画一条折线来完成。在初始化阶段甚至可以不需要将这个虚线框画出来。
function createSideDashLine(points) {
let dashLine = new Konva.Line({
points: points,
stroke: 'green',
strokeWidth: 2,
lineJoin: 'round',
dash: [4, 4],
visible: false,
})
return dashLine
}
let leftSideDashLine = createSideDashLine([])
let rightSideDashLine = createSideDashLine([])
layer.add(leftSideDashLine)
layer.add(rightSideDashLine)
然后将虚线与箭头关联。
leftArrow.relativeDashLine = leftSideDashLine
rightArrow.relativeDashLine = rightSideDashLine
在箭头被拖动时,不停地重绘虚线框地坐标,以达到动画效果。
function createArrow(startingPoint) {
...
arrow.initialPosition = (startingPoint > 0 ? rect.width() : 0) + rect.x() // 记录箭头初始位置
arrow.on('dragmove', () => {
arrow.y(0)
if ((startingPoint > 0 && arrow.x() < 0) || (startingPoint <= 0 && arrow.x() > 0)) {
arrow.relativeDashLine.hide() // 回归到边界后将虚线隐藏起来
arrow.x(0)
return
}
arrow.relativeDashLine.points([
arrow.initialPosition, rect.y(),
arrow.initialPosition + arrow.x(), rect.y(),
arrow.initialPosition + arrow.x(), rect.y() + rect.height(),
arrow.initialPosition, rect.y() + rect.height()
])
arrow.relativeDashLine.show()
})
return arrow
}
先给侧边扩增的部分也加一些文字标注。
let rightSideText = new Konva.Text({
x: 0,
y: rect.y() + rect.height() / 2, // 长方形的1/2高度 + 长方形在y轴上的偏移
text: '+0%',
})
let leftSideText = new Konva.Text({
x: 0,
y: rect.y() + rect.height() / 2, // 长方形的1/2高度 + 长方形在y轴上的偏移
text: '+0%',
})
layer.add(leftSideText)
layer.add(rightSideText)
leftSideText.offsetX(rightSideText.width() / 2)
leftSideText.hide()
rightSideText.offsetX(rightSideText.width() / 2)
rightSideText.hide()
leftArrow.relativeSideText = leftSideText
rightArrow.relativeSideText = rightSideText
为什么没有用之前的createText的逻辑?因为终究还是有点不一样,兼容起来有一点麻烦。所以干脆就直接写了。之后,在箭头的拖动逻辑里增加相关的处理。
arrow.on('dragmove', () => {
arrow.y(0)
if ((startingPoint > 0 && arrow.x() < 0) || (startingPoint <= 0 && arrow.x() > 0)) {
arrow.relativeDashLine.hide() // 回归到边界后将虚线隐藏起来
arrow.relativeSideText.hide()
arrow.x(0)
return
}
...
arrow.relativeSideText.x(arrow.initialPosition + arrow.x() / 2)
arrow.relativeSideText.text('+' + Math.abs(Math.round(arrow.x() / rect.width() * 100)) + '%')
...
Math.abs(arrow.x()) < arrow.relativeSideText.width()
? arrow.relativeSideText.hide()
: arrow.relativeSideText.show()
})
到此,基本功能就搭建完全了。
抽象与复用
虽然现在基本的功能已经搭建完了,但离真正的使用还有一定的距离。比如,创建线段的时候限定了具体的位置,标注文字时也限定了具体的比例。对于各个分割块,也没有标注各个区块所代表的含义。对于扩增部分,也不清楚扩增的含义。所以我们必定经历一个整理与重构的过程。
让我们用一个更高维度的视角来看待它的实现。对于这个图表的功能而言,使用时首先关注的肯定是各个区块的占比和含义。因此我们在生成这样的图表时,会自然地想要以一种配置的形式去实现。例如:
init([
{
label: '投资',
ratio: 0.3
},
{
label: '纳税',
ratio: 0.2
},
{
label: '储蓄',
ratio: 0.2
},
{
label: '消费',
ratio: 0.3
},
])
这样的做法,更符合人的思维习惯,先分出区块,再由区块的数量来确定分隔线的数量。这样做的好处在于可以配置分割块的数量,并且有机会给各个分割块标注其类型。
那么我们的init
应该怎么做呢?很简单,也是遵循我们之前的基础功能的实现思路,先画出线,配置线段与线段之间的关系,然后标注文字,配置线段与文字之间的关系等等。只不过这一次,我们需要将这些过程收拢到几个函数中去执行。
重构line的创建
其实不过是把之前用for循环创建的line的逻辑转移到一个函数里。并依据配置参数的信息,将线段初始化的位置信息替换为由配置参数中的具体比例所决定的位置。为了最小化重构过程中的影响,甚至还在init
函数里把lines
变量传出去。
// let lines = [] // 用来将line收集起来备用
// for (let i = 0; i < 3; i++) {
// let line = createLine([
// (i + 2) * 100, 0, // 200/300/400
// (i + 2) * 100, 150
// ])
// line.original = 100 * (i + 1) // 100为线段间隔宽度
// line.poc = 100 * (i + 1) // 记录line当前所处位置,用于计算ratio的值与坐标
// layer.add(line)
// lines.push(line)
// }
function initLines(blocks) {
let lines = []
let aggregateRatio = 0
for (let i = 0; i < blocks.length - 1; i++) {
aggregateRatio += blocks[i].ratio
let line = createLine([
rect.x() + aggregateRatio * rect.width(), 0,
rect.x() + aggregateRatio * rect.width(), 150
])
line.original = aggregateRatio * rect.width() // 线段间隔宽度
line.loc = aggregateRatio * rect.width() // 记录line当前所处位置,用于计算ratio的值与坐标
layer.add(line)
lines.push(line)
}
// 创建line之间的前后关系
for (let i = 0; i < lines.length; i++) {
if (i > 0) {
lines[i].prev = lines[i - 1]
}
if (i < lines.length - 1) {
lines[i].next = lines[i + 1]
}
}
return lines
}
function init(blocks) {
let lines = initLines(blocks)
return {
lines
}
}
let { lines } = init([
{
label: '投资',
ratio: 0.25
},
{
label: '纳税',
ratio: 0.25
},
{
label: '储蓄',
ratio: 0.25
},
{
label: '消费',
ratio: 0.25
},
])
重构ratio的创建
同样地,initRatios
函数的逻辑主要来自当初用for循环创建标注文字的部分。并且为了表明各个分割块的具体的意义,以及与边界的文字做一定程度的区分,我们将createRatio
函数改为createRatioText
函数并利用配置参数信息里的label属性。
其中初始化x的值为长方形偏移值 + 当前总体比例占长方形的长度 - 1/2的当前分割块的宽度
// let ratios = []
// for (let i = 0; i < 4; i++) {
// // 文字的x坐标为长方形的偏移 + 各个分割块的中点x值
// let ratio = createRatio('25%', (i + 1) * 100 + 50) // 150/250/350/450
// layer.add(ratio)
// ratios.push(ratio)
// }
function initRatios(blocks) {
let ratios = []
let aggregateRatio = 0
for (let i = 0; i < blocks.length; i++) {
aggregateRatio += blocks[i].ratio
// 文字的x坐标为长方形的偏移 + 各个分割块的中点x值
let ratio = createRatioText(
blocks[i].label,
blocks[i].ratio * 100 + '%',
rect.x() + (aggregateRatio * rect.width()) - (blocks[i].ratio * rect.width()) / 2
)
layer.add(ratio)
ratios.push(ratio)
}
return ratios
}
function init(blocks) {
let lines = initLines(blocks)
let ratios = initRatios(blocks)
// 创建line之间的前后关系
for (let i = 0; i < lines.length; i++) {
lines[i].leftRatio = ratios[i]
lines[i].rightRatio = ratios[i + 1]
}
// 实际上由于线段与标注文字的关系已经在该函数里配置完了,将这两个变量传导出去也没有用了
return {
lines,
ratios
}
}
function createRatioText(label, text, x) {
let ratio = new Konva.Text({
x,
y: rect.y() + rect.height() / 2, // 长方形的1/2高度 + 长方形在y轴上的偏移
text: label + '\n\n' + text,
})
ratio.offsetX(ratio.width() / 2) // 调整文字中心位置
ratio.offsetY(ratio.height() / 2) // 调整文字中心位置
ratio.update = function(leftBound = 0, rightBound = rect.width()) {
const blockWidth = rightBound - leftBound
ratio.x(blockWidth / 2 + leftBound + rect.x())
ratio.text(label + '\n\n' + Math.round(blockWidth / rect.width() * 100) + '%')
// 控制文字在边界情况下的显示与隐藏
blockWidth < ratio.width()
? ratio.hide()
: ratio.show()
}
return ratio
}
于是乎,我们就得到了一个可配置分割块数量的图表,且有具体意义的分割块:
init([
{
label: '投资',
ratio: 0.3
},
{
label: '纳税',
ratio: 0.2
},
{
label: '储蓄',
ratio: 0.2
},
{
label: '消费',
ratio: 0.1
},
{
label: '其他',
ratio: 0.2
}
])
重构左右箭头逻辑
将左右箭头的创建逻辑也收纳进init
函数之中
// let leftArrow = createArrow(-50)
// let rightArrow = createArrow(rect.width() + 50)
// layer.add(leftArrow)
// layer.add(rightArrow)
function initArrows() {
let leftArrow = createArrow(-50)
let rightArrow = createArrow(rect.width() + 50)
layer.add(leftArrow)
layer.add(rightArrow)
return {
leftArrow,
rightArrow
}
}
function init(blocks) {
let lines = initLines(blocks)
let ratios = initRatios(blocks)
// 创建line之间的前后关系
for (let i = 0; i < lines.length; i++) {
lines[i].leftRatio = ratios[i]
lines[i].rightRatio = ratios[i + 1]
}
// 创建左右箭头
let { leftArrow, rightArrow } = initArrows()
return {
lines,
ratios,
leftArrow,
rightArrow
}
}
let { leftArrow, rightArrow } = init(...)
以注册的方式建立虚线框
其实在做超出总体部分的逻辑时,发现不仅是虚线框的部分关联上了箭头,与扩张部分相关的边界文字也关联上了箭头。因此我就想,要不留一个域来保存这些关联的对象,并且暴露一个注册函数,用来将关联的对象保存进域中。
function createArrow(xPosition, offsetX) {
...
arrow.relative = {} // 用来保存所关联的图形对象
arrow.on('dragmove', () => {
...
})
// 自定义事件
arrow.relate = function(type, obj) {
arrow.relative[type] = obj
return arrow
}
return arrow
}
于是,对于虚线框,我们可以省略对它的命名,也简化了对它与箭头的关系绑定。甚至还可以将箭头dragmove
事件处理函数中对虚线框的处理,与显示/隐藏逻辑转移到创建虚线框的函数内部:
// let leftSideDashLine = createSideDashLine([])
// let rightSideDashLine = createSideDashLine([])
// layer.add(leftSideDashLine)
// layer.add(rightSideDashLine)
// leftArrow.relativeDashLine = leftSideDashLine
// rightArrow.relativeDashLine = rightSideDashLine
function init(blocks) {
...
// 创建左右箭头
let { leftArrow, rightArrow } = initArrows()
leftArrow
.relate('dashLine', createSideDashLine([]))
rightArrow
.relate('dashLine', createSideDashLine([]))
return {
lines,
ratios,
leftArrow,
rightArrow
}
}
function createSideDashLine(points) {
...
dashLine.repaint = function(initialPosition, x) {
this.points([
initialPosition, rect.y(),
initialPosition + x, rect.y(),
initialPosition + x, rect.y() + rect.height(),
initialPosition, rect.y() + rect.height()
])
x == 0 ? this.hide() : this.show()
}
layer.add(dashLine)
return dashLine
}
function createArrow(xPosition, offsetX) {
...
arrow.on('dragmove', () => {
arrow.y(0)
if ((startingPoint > 0 && arrow.x() < 0) || (startingPoint <= 0 && arrow.x() > 0)) {
// arrow.relativeDashLine.hide() // 回归到边界后将虚线隐藏起来
arrow.relativeSideText.hide()
arrow.x(0)
// return
}
arrow.relative.dashLine.repaint(arrow.initialPosition, arrow.x())
// arrow.relativeDashLine.points([
// arrow.initialPosition, rect.y(),
// arrow.initialPosition + arrow.x(), rect.y(),
// arrow.initialPosition + arrow.x(), rect.y() + rect.height(),
// arrow.initialPosition, rect.y() + rect.height()
// ])
arrow.relativeSideText.x(arrow.initialPosition + arrow.x() / 2)
arrow.relativeSideText.text('+' + Math.abs(Math.round(arrow.x() / rect.width() * 100)) + '%')
// arrow.relativeDashLine.show()
Math.abs(arrow.x()) < arrow.relativeSideText.width()
? arrow.relativeSideText.hide()
: arrow.relativeSideText.show()
})
...
}
以注册的方式建立边界文字
对于边界文件的部分,也是采用相似的手法:
function init(blocks) {
...
// 创建左右箭头
let { leftArrow, rightArrow } = initArrows()
leftArrow
.relate('dashLine', createSideDashLine([]))
.relate('sideText', createSideText())
rightArrow
.relate('dashLine', createSideDashLine([]))
.relate('sideText', createSideText())
return {
lines,
ratios,
leftArrow,
rightArrow
}
}
// let leftSideText = new Konva.Text({
// x: 0,
// y: rect.y() + rect.height() / 2, // 长方形的1/2高度 + 长方形在y轴上的偏移
// text: '+0%',
// })
// let rightSideText = new Konva.Text({
// x: 0,
// y: rect.y() + rect.height() / 2, // 长方形的1/2高度 + 长方形在y轴上的偏移
// text: '+0%',
// })
// layer.add(leftSideText)
// layer.add(rightSideText)
// leftSideText.offsetX(rightSideText.width() / 2)
// leftSideText.hide()
// rightSideText.offsetX(rightSideText.width() / 2)
// rightSideText.hide()
// leftArrow.relativeSideText = leftSideText
// rightArrow.relativeSideText = rightSideText
// 还是选择用一个函数将原先零散的边界文字创建逻辑封装进一个函数里
function createSideText() {
let sideText = new Konva.Text({
x: 0,
y: rect.y() + rect.height() / 2, // 长方形的1/2高度 + 长方形在y轴上的偏移
text: '+0%',
})
sideText.offsetX(sideText.width() / 2)
sideText.hide()
sideText.repaint = function(initialPosition, x) {
this.x(initialPosition + x / 2)
this.text('+' + Math.abs(Math.round(x / rect.width() * 100)) + '%')
Math.abs(x) < this.width()
? this.hide()
: this.show()
}
layer.add(sideText)
return sideText
}
function createArrow(xPosition, offsetX) {
arrow.on('dragmove', () => {
...
if ((xPosition > 0 && arrow.x() < 0) || (xPosition <= 0 && arrow.x() > 0)) {
//arrow.relative.dashLine.hide() // 回归到边界后将虚线隐藏起来
// arrow.relativeSideText.hide()
arrow.x(0)
// return
}
arrow.relative.dashLine.repaint(arrow.initialPosition, arrow.x())
arrow.relative.sideText.repaint(arrow.initialPosition, arrow.x())
...
// Math.abs(arrow.x()) < arrow.relativeSideText.width()
// ? arrow.relativeSideText.hide()
// : arrow.relativeSideText.show()
// arrow.relative.dashLine.show()
})
}
我们甚至还可以用一个for循环,来遍历执行所用域中的repait函数
function createArrow(xPosition, offsetX) {
arrow.on('dragmove', () => {
...
// 重绘与箭头相关的图形
for(let key in arrow.relative) {
arrow.relative[key].repaint(arrow.initialPosition, arrow.x())
}
...
})
}
优雅,太优雅了!这样一分离之后,各个图形之间的逻辑都被集成进自身之中,然后通过注册的形式去建立关联。
补充与完善
统计信息
当前的图表还有一些问题,在分割块较小时,为了不破坏图表的美观,标注文字会被隐藏起来,这是一种牺牲,也是一个功能缺陷,因为这样会看不到一些比例。为此,我选择增加一个统计信息部分将详细的统计信息显示出来。(细心的同学可能发现到目前为止,我们还没有解决一个问题——那就是扩增部分到底代表什么含义?我们将在这一步一块实现出来)
将统计信息放到和ratios生成时的过程中一同生成。并建立从ratio到统计信息的联系。
function initRatios(blocks) {
let ratios = []
let aggregateRatio = 0
let group = new Konva.Group({
x: 400,
y: 200,
})
for (let i = 0; i < blocks.length; i++) {
aggregateRatio += blocks[i].ratio
// 文字的x坐标为长方形的偏移 + 各个分割块的中点x值
let ratio = createRatioText(
blocks[i].label,
blocks[i].ratio * 100 + '%',
rect.x() + (aggregateRatio * rect.width()) - (blocks[i].ratio * rect.width())/ 2
)
let entry = createStatisticEntry(
blocks[i].label,
blocks[i].label + ' ---- ' + blocks[i].ratio * 100 + '%',
i * 20
)
ratio.relativeStatistics = entry
group.add(entry)
layer.add(ratio)
ratios.push(ratio)
}
layer.add(group)
return ratios
}
function createStatisticEntry(label, text, y) {
let entry = new Konva.Text({
x: 30,
y: y,
text: text
})
entry.label = label
return entry
}
题外话:为什么不像处理arrow一样做一个注册函数出来,然后将统计信息注册进去,这样ratio一改动,就去修改注册域里的对象?
我认为原因在于arrow与虚线框,与文字,是一个比较浅的绑定,即创建箭头时可以没有虚线框,没有边界文字,亦或者创建的是实线框,创建的是其他样式的文字,甚至再增加别的什么图形。所以如果把虚线框的逻辑,边界文字的逻辑写进arrow的函数里,当创建箭头时自动创建虚线框和边界文字,看起来好像很方便,但遇到改动的时候我们就得深入箭头的函数逻辑里,这很不好。并且箭头和其他图形对象的关联是弱的,这意味着随时都有可能有新的图形被关联到箭头里,或者有图形被撤销与箭头的关联。
而ratio与统计信息是强相关的,是一一对应的。因此,把统计信息的逻辑写进ratio的逻辑里有很强的合理性。选择把统计信息的逻辑加到initRatios这个函数里,表明它们的强关联关系,一有你就有我。而不把它加到createRatioText这个函数里,从而保持一些功能的单一性。
接下来考虑分割块比例变动后,如何对应地更新统计信息。将每个比例与对应的统计项对应起来,当比例变动时在update
事件回调函数里更新统计信息即可。
当ratio的值在修改的时候,我们将同样的值赋予统计信息上
function createRatioText(label, text, x) {
...
ratio.update = function(leftBound = 0, rightBound = rect.width()) {
...
// 更新统计
ratio.relativeStatistics.text(label + ' ---- ' + Math.round(blockWidth / rect.width() * 100) + '%')
...
}
return ratio
}
为什么不将统计信息的修改与ratio的修改一样,通过线段的变动来触发修改呢?
因为ratio与线段的关系是直观的,线段一变动,自然会去想要改它左右两侧的比例,所以它们之间建立联系很合理。而统计信息与ratio直接相关,离线段的关系远了一层,如果直接建立联系则有点莫名其妙。
此外,箭头移动所代表的扩增部分还没有指明其意义。我的想法是,扩增部分所表达的增加就体现在统计信息上。
由于在arrow处增加了对统计信息的关联,因此需要写下相应的repaint
逻辑。比例变动时会修改统计信息,箭头变动时也会修改比例信息,两处都修改统计信息时,我们写一个update
函数兼容二者。
function init(blocks) {
...
// 创建左右箭头
let { leftArrow, rightArrow } = initArrows()
leftArrow
.relate('dashLine', createSideDashLine([]))
.relate('sideText', createSideText())
.relate('statistic', ratios[0].relativeStatistics)
rightArrow
.relate('dashLine', createSideDashLine([]))
.relate('sideText', createSideText())
.relate('statistic', ratios.at(-1).relativeStatistics)
...
}
function createStatisticEntry(label, text, y) {
let entry = new Konva.Text({
x: 30,
y: y,
text: text
})
entry.label = label
entry.extra = 0
entry.value = Number(text.match(/\d+/g)[0])
entry.update = function(num) {
if (num) {
this.value = num
}
if (this.extra > 0) {
entry.text(entry.label + ' ---- ' + this.value + '%' + `(+${ this.extra }%)`)
} else {
entry.text(entry.label + ' ---- ' + this.value + '%')
}
}
entry.repaint = function(initialPosition, x) {
this.extra = parseInt(Math.abs(x) / rect.width() * 100)
this.update()
}
return entry
}
function createRatioText(label, text, x) {
...
ratio.update = function(leftBound = 0, rightBound = rect.width()) {
...
// 更新统计
ratio.relativeStatistics.update(Math.round(blockWidth / rect.width() * 100))
...
}
return ratio
}
到这里,整个功能就完成了。
不足
我认为有三个不足:
- 抽象不够彻底,还有更进一步的重构空间
- 不美观
- 精度不足,计算比例时会出现误差
也许还有其他没有考虑的东西,以及还有哪里是表述不到位的地方,请多多指教,一起进步。
转载自:https://juejin.cn/post/7415925899654938661