从零打造Echarts —— V7 文本和完结
February 25, 2019 · View on GitHub
本文开始v7版本。
回顾v6
在v6版本中我们添加了事件处理的功能。
Text
一个图表应用,文本显然是必不可少的内容,而canvas中的文本,并没有想象中那么简单。本版本中将完成大功能的最后一项——文本,,完成之后XRender即可暂时收工了。
根据之前的经验,很容易创建Text元素。
import XElement, { XElementShape, XElementOptions } from './XElement'
interface TextShape extends XElementShape {
x?: number
y?: number
/**
* 要绘制的文本
*/
text?: string
}
interface TextOptions extends XElementOptions {
shape?: TextShape
}
class Text extends XElement {
name = 'text'
shape: TextShape = {
text: '',
x: 0,
y: 0
}
constructor (opt: TextOptions = {}) {
super(opt)
this.updateOptions()
}
render (ctx: CanvasRenderingContext2D) {
let shape = this.shape
if (this.hasFill()) {
ctx.fillText(shape.text, shape.x, shape.y)
}
if (this.hasStroke()) {
ctx.strokeText(shape.text, shape.x, shape.y)
}
}
}
export default Text
// App.vue
let text = new xrender.Text({
shape: {
text: '这是一个文字',
x: 0,
y: 0
},
style: {
lineWidth: 5,
fill: '#0f0',
font: '24px serif'
}
})
xr.add(text)

应用之后却发现画布上什么都没有——哦不对,仔细观察可以发现左上角有一撮阴影。这是因为canvas绘制文本时会依据坐标和基线来绘制的。也就是textBaseline和textAlign。除了这两个样式,文本专属的样式还有font,为了使用方便,将其拆分为fontSize和fontFamily。修改bindStyle和添加默认style。
// XElement.ts
function bindStyle () {
//...
let font = `${style.fontSize}px ${style.fontFamily}`
ctx.font = font;
['lineWidth', 'shadowBlur', 'shadowColor', 'shadowOffsetX', 'shadowOffsetY', 'textBaseline', 'textAlign'].forEach(prop => {
if (style[prop] !== ctx[prop]) {
ctx[prop] = style[prop]
}
})
}
class XElement {
style: XElementStyle = {
// ...
fontSize: 12,
fontFamily: 'serif',
textAlign: 'left',
textBaseline: 'top'
}
}
再看看结果:

超出隐藏、换行和超出显示省略
标题所示的三个功能都是很常见的功能,然而canvas对文本排版的支持非常弱,是无法实现自动换行的。但是用手动来检测的话也很简单,计算文本宽度,找出要换行时的位置,对文本分批绘制即可。而省略与此相同。
超出隐藏则无法这么简单地实现。为了实现超出隐藏,我们需要引入新的概念——clip,裁剪。要想实现我们的目的,只需要在绘制之前,绘制一遍裁剪路径(文本超出隐藏需要的裁剪路径为矩形),然后调用ctx.clip(不熟悉api的请自行查阅)即可。而因为之前的苦工,我们可以将元素用于裁剪路径。
为XElement添加如下参数。
class XElement {
/**
* 用于裁剪的元素,只能通过`setClip`设置
*/
clip: XElement
/**
* 是否被用于裁剪,如果是的话,不会进行描边和填充
*/
isClip: boolean
/**
* 绘制之前进行样式的处理
*/
beforeRender (ctx: CanvasRenderingContext2D) {
ctx.save()
// 需要注意的是,裁剪路径有自己的`transform`体系,为了让裁剪路径和元素本身有相同的相对变换,需要在`setClip`中设置parent
this.setCtxClip(ctx)
this.handleParentBeforeRender(ctx)
ctx.save()
bindStyle(ctx, this.style)
this.setTransform(ctx)
ctx.beginPath()
}
/**
* 绘制之后进行还原
*/
afterRender (ctx: CanvasRenderingContext2D) {
if (this.hasFill() && !this.isClip) {
ctx.fill()
}
if (this.hasStroke() && !this.isClip) {
ctx.stroke()
}
// ...
// 在最后,重置裁剪
ctx.restore()
}
setParent (parent: Group) {
//...
// 更新裁剪路径的父元素
if (this.clip) {
this.setClip(this.clip)
}
}
/**
* 设置裁剪路径
*/
setClip (xel: XElement) {
this.clip = xel
// 为了能应用变换
if (this.parent) {
// 但又不被`getAll`所获取
xel.ignored = true
xel.setParent(this.parent)
xel.options.relativeGroup = this.relativeGroup
// 否则会不断循环
xel._xr = null
}
this.dirty()
}
/**
* 移除裁剪路径
*/
removeClip () {
this.clip.ignored = false
this.clip = null
this.dirty()
}
/**
* 为上下文设定裁剪路径
*/
setCtxClip (ctx: CanvasRenderingContext2D) {
if (this.clip) {
this.clip.isClip = true
this.clip.refresh(ctx)
this.clip.isClip = false
ctx.clip()
}
}
updateOptions (opt?: XElementOptions) {
//...
['zLevel', 'relativeGroup', 'zIndex', 'renderByFrame'].forEach(key => {
if (opt[key] !== undefined) {
this[key] = opt[key]
// 设置`clip`的相对定位元素
if (key === 'relativeGroup') {
this.clip && this.clip.attr({
relativeGroup: opt[key]
})
}
}
})
}
/**
* 是否包含某个点
*/
contain (x: number, y: number) {
// 首先要被裁剪路径包含
if (this.clip) {
if (!this.clip.contain(x, y)) {
return
}
}
//...
}
}
应用
group1.add(rect)
rect.setClip(curve)
xr.add(group1)
结果如图:

简单测试了一下其它方面也没有出问题。
现在我们回到Text。有了以上的裁剪基础,就可以实现最开始想要的功能了。
添加参数如下
interface TextShape extends XElementShape {
/**
* 此`maxWidth`不同于`canvas`绘制时的`maxWidth`,用来控制换行和省略的
*/
maxWidth?: number
maxHeight?: number
/**
* 是否允许换行,默认不换行
*/
wrap?: boolean
/**
* 超出部分如何显示
*/
overflow?: 'hidden' | 'visible' | 'ellipsis'
}
为指定的宽高创建裁剪路径
class Text {
updateOptions(opt?) {
super.updateOptions(opt)
this.updateClipRect()
}
updateClipRect () {
if (this.shape.overflow === 'visible') {
return
}
let shape = this.shape
let opt = {
scale: clone(this.scale),
rotation: this.rotation,
position: clone(this.position),
origin: clone(this.origin),
shape: {
x: shape.x,
y: shape.y,
width: shape.maxWidth ? shape.maxWidth : 100000000000000,
// 应该是文本的高度,暂时忽略
height: shape.maxHeight ? shape.maxHeight : 100000000000000
}
}
let rect = this.clip
if (!rect) {
rect = new Rect(opt)
this.setClip(rect)
} else {
rect.attr(opt)
}
}
}
效果如图:

接下来就可以准备换行和省略号的事宜了,开始之前想一想,对于文本超出给定高度的情况如何处理?是超出隐藏,还是多行省略(可能会有很多不必要的空白),或者可以滚动(做肯定是可以做,但是多半走远了)?暂时选择前两者吧。
超出显示省略号
canvas提供了mesureText方法来检测给定文本的宽度,而且结果非常精准。因此,对于超出显示省略号的功能,可以先计算出省略号的宽度,然后用给定的最大宽度减去省略号宽度去找出文本换行的分界线。
准备一些辅助方法,创建util/text.ts:
// 按行绘制
export interface LineText {
x: number
y: number
text: string
width: number
}
function createLineText (x, y, text, width) {
return {
x,
y,
text,
width
}
}
const ellipsis = '...'
/**
* 获取换行后的文本
*/
export function getWrapText(
startX: number, startY: number, ctx: CanvasRenderingContext2D,
text: string, shape: TextShape, style: XElementStyle
): LineText[] {
// 没有指定宽度的话直接返回即可,没有指定宽度的话指定了高度也没用
if (!shape.maxWidth) {
return [createLineText(startX, startY, text)]
}
let result = []
let len = text.length
let maxWidth = shape.maxWidth
// 省略号的长度
let ellipsisLength = ctx.measureText(ellipsis).width
// 首先,不换行
if (!shape.wrap || (lineHeight * 2 > maxHeight)) {
switch (shape.overflow) {
// 可见和隐藏不需要做更多
case 'visible':
case 'hidden':
result = [createLineText(startX, startY, text, 0)]
// 省略号算出长度即可
case 'ellipsis':
let { index, width } = findTextIndex(ctx, text, maxWidth)
// 如果当前宽度不能满足需要,则添加省略号
if (index < len - 1) {
let indexData = findTextIndex(ctx, text, maxWidth - ellipsisLength)
text = indexData.text + ellipsis
width = indexData.width + ellipsisLength
}
result = [createLineText(startX, startY, text, width)]
}
} else {
// ...
}
}
interface TextIndex {
/**
* 索引
*/
index: number
/**
* 文本片段
*/
text: string
/**
* 文本片段的宽度
*/
width: number
}
/**
* 根据最大宽度找到索引
*/
function findTextIndex (ctx: CanvasRenderingContext2D, text: string, maxWidth: number): TextIndex {
if (!text) {
return null
}
let measureText = text => ctx.measureText(text)
let len = text.length
let textWidth = measureText(text).width
let result: TextIndex = {
index: len - 1,
text,
// 返回宽度是因为后面要用到
width: textWidth
}
// 宽度已经满足要求
if (textWidth <= maxWidth) {
return result
}
// 取中间的索引
let halfLen = Math.floor(len / 2)
let halfText = text.slice(0, halfLen ? halfLen : 1)
result.text = halfText
textWidth = measureText(halfText).width
result.width = textWidth
// 同上
if (textWidth === maxWidth) {
result.index = (halfLen ? halfLen : 1) - 1
return result
}
// 如果文本一半的宽度小于最大宽度,向后取
if (textWidth < maxWidth) {
let nextIndex = findTextIndex(ctx, text.slice(halfLen), maxWidth - textWidth)
if (nextIndex !== null) {
halfLen += (nextIndex.index + 1)
result.text += nextIndex.text
result.width += nextIndex.width
}
result.index = halfLen - 1
return result
}
// 分到第一个还无法满足需求
if (halfLen === 0) {
return null
}
// 如果一半仍然大于,向前取
return findTextIndex(ctx, text.slice(0, halfLen - 1), maxWidth)
}
/**
* 绘制文本
*/
export function renderText (ctx: CanvasRenderingContext2D, lineTexts: LineText[], method: string) {
let lineText
for (let i = 0; i < lineTexts.length; i += 1) {
lineText = lineTexts[i]
ctx[method](lineText.text, lineText.x, lineText.y)
}
}
然后应用:
class Text {
render (ctx: CanvasRenderingContext2D) {
// ...
let lineTexts = getWrapText(x, y, ctx, shape.text, shape, this.style)
if (this.hasFill()) {
renderText(ctx, lineTexts, 'fillText')
}
if (this.hasStroke()) {
renderText(ctx, lineTexts, 'strokeText')
}
}
}
结果如图(为了效果明显,加上了背景色):

换行
通过上面的方法,我们可以很容易找出需要换行的边界索引,但是想要分批绘制,还需要知道一个关键的信息,那就是行高。canvas里的行高是多少?上图的字体大小为48px,背景矩形的高度也为48px,看起来就和line-height: 1的效果相同,即行高等于字体大小。看起来似乎是这么回事。然而如果我们改变字体,font-family: Arial,那么结果如下:

可以很明显地看出,它的行高要远远超出字体大小——当然,这可能是因为字体太大了,但是可以确定的是,它的行高和字体高度不同。
而canvas是没有提供检测行高的方法的,那么可不可以借助其它方式呢?比如借助HTML的getComputedStyle?这个想法很美好,然而一个残酷的点是,HTML中的行高并不会因为字体的改变而改变,这是因为行高的含义本来就不是文字所占有的高度,而我们要绘制多行文本,需要的恰好就是文字所占高度。
而这是可以计算出来的。如何计算?首先看两张图片:


第一张图字体为serif,第二张图字体为Arial,可以看出最接近底部的字符不是g(英文字母g, \u0067)就是ŋ(\u014B),虽然没有测试其它字体,但是想来都差不多。同样的道理,可以使用字符家来测量字体的最高点。也就是说,如果能计算出g和ŋ最底部所在的位置(bottom),取其大者,再加个1到2px,和家最顶部的位置(top),那么当前字体设置下在y轴上最多占用多少个像素也就能够知道了。
上面的字都是以textBaseline = top为基准绘制的,同样的如果以bottom为基准来绘制,则可以获取最底部到空白分界线的位置(bottomOffset)。
那么怎么得出这些数据呢?我能想到的办法是,使用像素点来判断。首先用clearRect在画布左上角清空出一区域,在(0, 0)处绘制g,调用getImageData获取这片区域的像素点,假设当前的fontSize为48,查询第48行是否有像素点,如果没有,向前查找,直到找到有像素点的行为止。如果有,向后查找,找到没有像素点的行为止。家从0开始,同理。对于bottomOffset也是差不多的做法。
实际上,经过测试几种字体和文字,得出结果是,只要指定了字体和字体大小,大部分常用的文字(汉字、英文、标点)的bottom + bottomOffset的值都相同,且这个值基本等同于我们要找的lineHeight。而其它稀奇古怪的符号则会超出这个值,比如႟ (\u109f)和ŋ(对于top值同样如此,不过偏差并不多)。那么该如何抉择呢?计算每一个字的高度是否可行呢?答案是不行,即使是文字不多的情况,也会占用大量的时间。所以,为了大多数情况下的美观,我决定不考虑其它字符的情况。

既然bottom + bottomOffset就可以确定行高,那么要top值做什么呢?如果以top为基准来绘制文本,那么顶部一般会留一定的空白,有时候又不想要这些空白,想让文本(指本行中最高的字,下同)紧贴顶部,在绘制文本时就需要减去top的值来决定纵坐标。虽然可能也会想让文本紧贴底部,但是一般来说当以bottom为基准绘制时,文本本身已经比较紧贴了,只有很短的距离。
- 需要留有空白。

- 不需要留有空白。

但是,本文并不打算给出可配置的选项,为了简单,采用不留空白的方式来绘制,空白可以通过添加行间距的配置来形成,不过这里就不做这一项了。
说了这么多,究竟该如何计算呢?
具体方法如下:
// util/text.ts
let ctxForLineHeight: CanvasRenderingContext2D
let canvasForLineHeight: HTMLCanvasElement
function createCtx () {
canvasForLineHeight = document.createElement('canvas')
// 一般来说测量行高应该够用了
canvasForLineHeight.width = 1000
canvasForLineHeight.height = 1000
// 是的,即使display为none,也能取到数据
canvasForLineHeight.style.display = 'none'
document.body.appendChild(canvasForLineHeight)
ctxForLineHeight = canvasForLineHeight.getContext('2d')
}
/**
* 用字体和比值计算
*/
const FontsData = {
example: {
fontSize: 12,
lineHeight: 14,
top: 0
}
}
const chars = {
// g
g: '\u0067',
// ŋ
n: '\u014B',
// 家
q: '\u5bb6'
}
/**
* 将imageData转为二维数组
*/
function sliceImageData (data: Uint8ClampedArray, width: number, height: number) {
let result = []
let len = 0
for (let i = 0; i < height; i += 1) {
let row = result[i] = []
for (let j = 0; j < width; j += 1) {
row.push([
data[len++],
data[len++],
data[len++],
data[len++]
])
}
}
return result
}
/**
* 查询某一行是否有像素
*/
function hasPx (data: any[], row: number) {
return data[row].filter(item => item[3]).length !== 0
}
/**
* 获取单个字符的高度
*/
function getCharRange (char: string, width: number) {
// 可能会超出范围
let clearWidth = 1.5 * width
ctxForLineHeight.clearRect(0, 0, clearWidth, clearWidth)
// 在顶部画字
ctxForLineHeight.textBaseline = 'top'
// 画一个字
ctxForLineHeight.fillText(char, 0, 0)
// 获取它该有的像素
let imgData = sliceImageData(
ctxForLineHeight.getImageData(0, 0, clearWidth, clearWidth).data,
clearWidth,
clearWidth
)
let top = 0
let bottom = width - 1
let rowHasPx = hasPx(imgData, bottom)
if (rowHasPx) {
// 向后取直到没有像素为止
while ((bottom < clearWidth) && hasPx(imgData, bottom)) {
bottom += 1
}
} else {
// 向前取
while ((bottom >= 0) && !hasPx(imgData, bottom)) {
bottom -= 1
}
}
while ((top < clearWidth) && !hasPx(imgData, top)) {
top += 1
}
// 在底部画字
ctxForLineHeight.clearRect(0, 0, clearWidth, clearWidth)
ctxForLineHeight.textBaseline = 'bottom'
ctxForLineHeight.fillText(char, 0, clearWidth)
imgData = sliceImageData(
ctxForLineHeight.getImageData(0, 0, clearWidth, clearWidth).data,
clearWidth,
clearWidth
)
let bottomOffset = clearWidth - 1
while ((bottomOffset >= 0) && !hasPx(imgData, bottomOffset)) {
bottomOffset -= 1
}
bottomOffset = clearWidth - bottomOffset
return {
top,
bottom,
bottomOffset,
lineHeight: bottom + bottomOffset
}
}
/**
* 根据当前样式获取文本行高和偏移
*/
export function getFontData (style: XElementStyle) {
if (!ctxForLineHeight) {
createCtx()
}
let fontFamily = style.fontFamily
let fontData = FontsData[fontFamily]
if (fontData) {
return {
lineHeight: fontData.lineHeight / fontData.fontSize * style.fontSize,
top: fontData.top / fontData.fontSize * style.fontSize
}
}
let font = `${style.fontSize}px ${fontFamily}`
ctxForLineHeight.save()
ctxForLineHeight.font = font
ctxForLineHeight.setTransform(1, 0, 0, 1, 0, 0)
// 重置样式
ctxForLineHeight.textBaseline = 'top'
ctxForLineHeight.fillStyle = '#000'
// 这是国字的高度
let width = ctxForLineHeight.measureText('国').width
let lineHeight = getCharRange(chars.g, width).lineHeight
let top = getCharRange(chars.q, width).top
lineHeight -= top
// 再加1px以免超出
lineHeight += 1
ctxForLineHeight.restore()
FontsData[fontFamily] = {
fontSize: style.fontSize,
lineHeight,
top
}
return FontsData[fontFamily]
}
得出了行高,就可以开始写换行的代码了。事实上得出行高的数据之后,不仅可以使用maxHeight来限制,也可以使用行数来限制,二者其实是等同的。同样的,为Text设置的裁剪框也能更加精确了——为什么?因为maxHeight本身的含义就是最大高度,而不是指定它的高度,有了行高之后,如果设置了最大高度,我们可以得到更准确的高度数据。所以将这一步放到设置clip之前进行。
class Text {
fontData = {
top: 0,
lineHeight: 12
}
// 在设置裁剪路径前获取
updateOptions(opt?: TextOptions) {
super.updateOptions(opt)
opt = opt || this.options
const fontData = this.fontData = getFontData(this.style)
const shape = opt.shape
// 有时候就是想
// 二者只能取一个
if (shape.rows) {
this.shape.maxHeight = this.shape.rows * fontData.lineHeight
}
this.updateClipRect()
}
// render时将数据传入即可
// 需要注意的是y = y - this.fontData.top
}
然后是开启换行时如何获取数据:
// util/text.ts
export function getWrapText(
startX: number, startY: number, ctx: CanvasRenderingContext2D,
text: string, shape: TextShape, style: XElementStyle, lineHeight: number
): LineText[] {
// 没有指定宽度的话直接返回即可,没有指定宽度的话指定了高度也没用
if (!shape.maxWidth) {
return [createLineText(startX, startY, text), ctx.measureText(text).width]
}
let result = []
let len = text.length
let maxWidth = shape.maxWidth
let maxHeight = shape.maxHeight
// 省略号的长度
let ellipsisLength = ctx.measureText(ellipsis).width
// 首先,不换行
if (!shape.wrap || (lineHeight * 2 > maxHeight)) {
//...
} else {
let startIndex = 0
let indexData = findTextIndex(ctx, text, maxWidth)
let index = indexData.index
result.push(createLineText(startX, startY, indexData.text, indexData.width))
while (index < len) {
startIndex = index + 1
indexData = findTextIndex(ctx, text.slice(startIndex), maxWidth)
if (!indexData) {
break
}
index += (indexData.index + 1)
startY += lineHeight
let subText = text.slice(startIndex, index)
// 最后一段超出高度,在后面加省略号,即多行超出省略
if (startY + lineHeight * 2 > maxHeight && (shape.overflow === 'ellipsis')) {
indexData = findTextIndex(ctx, subText, maxWidth)
if (!indexData) {
break
}
// 如果当前宽度不能满足需要,则添加省略号
if (indexData.index < subText.length - 1 || (index < len - 1)) {
indexData = findTextIndex(ctx, subText, maxWidth - ellipsisLength)
indexData.text += ellipsis
indexData.width += ellipsisLength
}
result.push(createLineText(startX, startY, indexData.text, indexData.width))
break
}
result.push(createLineText(startX, startY, indexData.text, indexData.width))
}
}
ctx.restore()
return result
}
}
设置rows为2,结果如图:

设置maxHeight为2.9 * lineHeight:

应该可以说是比较精准了。
修复动画的一个bug
使用动画的时候发现了一个bug,它会因为XElment.shape而影响到XElement.options,导致需要判断options时会出错。现修复如下:
// util/index.ts
/**
* 从原始属性中提取要使用动画的属性,返回一个新对象,否则可能对原来的opt造成污染
*/
export function getAnimationTarget (origin: Object, target: Object) {
const animationTarget = {}
for (let key in target) {
if (isObject(target[key]) && isObject(origin[key])) {
animationTarget[key] = getAnimationTarget(origin[key], target[key])
} else {
animationTarget[key] = origin[key]
}
}
return animationTarget
}
class XElement {
animateTo () {
// ...
this.animation = new Animation(getAnimationTarget(this, target))
// ...
}
}
内边距和填充
上图有一个问题,就是文本和边缘太过紧密(这是为了精确计算所不能少的),一般来说会需要它留有一定的间距。再考虑现在想实现一个非常简单的带文本的矩形按钮,应该怎么做?可以用Group组合Rect和Text,但其实容易想到的是,文本本身也是可以有边距和背景的——为它创建一个包围盒,就像html中的p标签等。也许这样会简单一些。这样的话Text也能响应事件了。为此需要计算文本的高度,并重新计算裁剪路径的数据——正好之前裁剪路径就设计得不够完整。如果一切顺利,那么结果应该如下图:

首先为文本创建一个包围盒boungdingRect,并在shape加入padding选项——为什么放在shpae而不是style中?因为,我也不知道。解析选项和绘制很简单,需要注意的是绘制的时机必须在绘制裁剪路径前绘制包围盒。代码如下:
// util/text.ts
/**
* 解析padding为标准格式
*/
export function parsePadding (padding: number[] | number) {
let result = []
if (Array.isArray(padding)) {
switch (padding.length) {
case 1:
padding = padding[0]
break
case 2:
case 3:
result[0] = padding[0]
result[1] = padding[1]
result[2] = padding[0]
result[3] = padding[1]
break
default:
result = padding.slice(0, 4)
break
}
}
if (!result.length) {
result = [padding, padding, padding, padding]
}
return result
}
// Text.ts
interface TextShape extends XElementShape {
/**
* 边距
*/
padding?: number[] | number
}
interface TextOptions extends XElementOptions {
shape?: TextShape
/**
* 为包围盒应用的样式
*/
rectStyle?: XElementStyle
}
class Text {
boundingRect: Rect
lineTexts: LineText[] = []
rectStyle: XElementStyle = {
fill: 'none',
stroke: 'none',
lineWidth: 1,
opacity: 1,
cursor: 'pointer',
fontSize: 12,
fontFamily: 'serif',
textAlign: 'left',
textBaseline: 'top'
}
updateOptions(opt?: TextOptions) {
super.updateOptions(opt)
opt = opt || this.options
if (opt.rectStyle) {
merge(this.rectStyle, opt.rectStyle)
}
// ...
}
updateClipRect (x: number, y: number, width: number, height: number) {
let opt = {
scale: clone(this.scale),
rotation: this.rotation,
position: clone(this.position),
origin: clone(this.origin),
shape: {
x,
y,
width,
height
}
}
let rect = this.clip
if (!rect) {
rect = new Rect(opt)
this.setClip(rect)
} else {
rect.attr(opt)
}
}
updateBoundingRect (x: number, y: number, textWidth: number, textHeight: number, padding: number[]) {
let opt = {
scale: clone(this.scale),
rotation: this.rotation,
position: clone(this.position),
origin: clone(this.origin),
shape: {
x,
y,
width: textWidth + padding[1] + padding[3],
height: textHeight + padding[0] + padding[2]
},
style: this.rectStyle,
relativeGroup: this.relativeGroup
}
let rect = this.boundingRect
if (!rect) {
rect = new Rect(opt)
} else {
rect.attr(opt)
}
// 它和Text用相同的变换
if (this.parent) {
// 仅仅用于绘制时判断,所以不需要调用setParent
rect.parent = this.parent
}
this.boundingRect = rect
}
beforeRender (ctx: CanvasRenderingContext2D) {
super.beforeRender(ctx)
}
render (ctx: CanvasRenderingContext2D) {
if (this.hasFill()) {
renderText(ctx, this.lineTexts, 'fillText')
}
if (this.hasStroke()) {
renderText(ctx, this.lineTexts, 'strokeText')
}
}
beforeSetCtxClip (ctx: CanvasRenderingContext2D) {
// 虽然放在这里不是很合适,但是目前只能想到这么做来避免被裁剪掉
let shape = this.shape
let x = shape.x
let y = shape.y
let padding = parsePadding(this.shape.padding)
x += padding[3]
y += padding[0]
let lineHeight = this.fontData.lineHeight
let lineTexts = getWrapText(
// 这里就减去top
x, y - this.fontData.top, ctx, shape.text,
shape, this.style, lineHeight, padding
)
this.lineTexts = lineTexts
this.updateClipRect(x, y, shape.maxWidth, lineTexts.length * lineHeight)
x -= padding[3]
y -= padding[0]
this.updateBoundingRect(x, y, shape.maxWidth, lineTexts.length * lineHeight, padding)
this.boundingRect.refresh(ctx)
this.clip.refresh(ctx)
}
setCtxClip (ctx: CanvasRenderingContext2D) {
this.beforeSetCtxClip(ctx)
super.setCtxClip(ctx)
}
contain (x: number, y: number) {
// 变换和描边等
return this.boundingRect.contain(x, y)
}
getBoundingRect () {
return this.boundingRect.getBoundingRect()
}
}
// uilt/text.ts
function getWrapText () {
// ...
let maxWidth = shape.maxWidth - padding[1] - padding[3]
let maxHeight = (shape.maxHeight || 100000000) - padding[0] - padding[2] + startY
}
现在代码可以跑起来了,但是还有两个问题。
第一个问题,现在裁剪路径的定位是(x, y),在textBaseLine = top, textAlign = left时表现正常,然而更改这些定位方式后,包围盒就会变得很奇怪。解决办法也很简单,根据上面两个属性的值结合lineHeight来进行调整即可。为了方便计算,需要限定这两个属性的值。
// XElement.ts
function bindStyle () {
// ...
let textBaseline = style.textBaseline
if (['top', 'middle', 'bottom'].indexOf(textBaseline) === -1) {
// 默认为顶部
textBaseline = 'top'
// 要更新style里的值
style.textBaseline = textBaseline
}
ctx.textBaseline = 'top'
let textAlign = style.textAlign
if (['left', 'center', 'right'].indexOf(textAlign) === -1) {
// 默认为左侧
textAlign = 'left'
style.textAlign = style.textAlign
}
ctx.textAlign = 'left'
}
然后根据之前的getWrapText中返回文本的长度,最后就可以根据这些来调整了。
与此同时,对于宽高限制,新增width和height属性,和max*的区别是:
- 认为在使用
maxHeight和maxWidth时,Text的包围盒和裁剪路径将自动形成,只不过不会超过给定的限制,且它们已经包含了内边距。 width和height同样具有限制的效果,但是它们会指定包围盒和裁剪路径的大小。
代码如下:
class Text {
beforeSetCtxClip (ctx: CanvasRenderingContext2D) {
// 虽然放在这里不是很合适,但是目前只能想到这么做来避免被裁剪掉
let shape = this.shape
let x = shape.x
let y = shape.y
let clipX = x
let clipY = y
let padding = parsePadding(this.shape.padding)
let lineHeight = this.fontData.lineHeight
clipY += padding[0]
clipX += padding[3]
let lineTexts = getWrapText(
// 这里就减去top
clipX, clipY - this.fontData.top, ctx, shape.text,
shape, this.style, lineHeight, padding
)
let textWidth = Math.max(...lineTexts.map(lineText => lineText.width))
let textHeight = lineTexts.length * lineHeight
let clipWidth = textWidth
let clipHeight = textHeight
let boundingRectWidth = textWidth + padding[1] + padding[3]
let boundingRectHeight = textHeight + padding[0] + padding[2]
if (shape.height) {
clipHeight = shape.height - padding[0] - padding[2]
boundingRectHeight = shape.height
}
if (shape.width) {
clipWidth = shape.width - padding[1] - padding[3]
boundingRectWidth = shape.width
}
let offsetX = 0
let offsetY = 0
switch (this.style.textAlign) {
case 'right':
offsetX = -boundingRectWidth
break
case 'center':
offsetX = -boundingRectWidth / 2
break
case 'left':
default:
break
}
switch (this.style.textBaseline) {
case 'bottom':
offsetY = -boundingRectHeight
break
case 'middle':
offsetY = -boundingRectHeight / 2
break
case 'top':
default:
break
}
clipX += offsetX
clipY += offsetY
x += offsetX
y += offsetY
this.lineTexts = lineTexts.map(lineText => {
lineText.y += offsetY
lineText.x += offsetX
return lineText
})
this.updateClipRect(clipX, clipY, clipWidth, clipHeight)
this.updateBoundingRect(x, y, boundingRectWidth, boundingRectHeight)
this.boundingRect.refresh(ctx)
}
}
效果如图:

虽然还有很多功能可以添加,不过这样的文本应该能满足一般的需求了,其它功能在此基础上简单添加即可,比如行间距,字间距。至此,Text,就已经创建完毕。
清理内存
在之前的内容中我们一直都是在添加元素后就没有增删过,而如果要增删,就要管理好内存,否则可能会造成内存泄漏导致应用崩溃。就和前面提到的一样,为类添加dispose方法即可。代码内已经添加完毕,这里就不贴出来占地方了。
bug修复
最开始设计的渲染逻辑和加入Layer后的有较大变化,而在添加Layer的版本中只关注了改时是否能正常变动,忽略了增删,因此存在一些bug,现修复如下。
delete
之前的设计中,更新的时候Layer要该层有元素标记为drity才会更新,而如果将一个元素删除,Layer根本获取不到这个元素,也就无法更新了。当然,可以删除元素时调用dirty方法,重新绘制之后再将元素从Stage移除,但是dirty过后我们并不知道需要多久才能完成重绘,所以这个方法行不通。
另一个思路是为Layer保留结束索引,如果有有删除的元素,那么遍历元素时得到的结束索引和原先的结束索引应该不一致,不过能够改变这个值的因素太多,所以也不太行。
那么我能想到的方法是,调用Storage.delete时并不删除元素,而将此元素标记为待删除,更新时进行判断即可。
class XElement {
/**
* 自身所关联的stage
*/
__stage: Stage
/**
* 是否正在被删除,`Layer`遇到此标记等同于`drity`,然后调用删除自身的方法
* 而`Stage`删除元素时此标记为真会将此元素删除,否则标记为真,然后调用`dirty()`
*/
deleteing = false
removeSelf () {
this.__stage.delete(this)
}
}
class Stage {
add (...xelements: XElement[]) {
for (let i = 0; i < xelements.length; i += 1) {
xelements[i].__stage = this
this.xelements.push(xelements[i])
}
}
delete (xel: XElement) {
let index = this.xelements.indexOf(xel)
if (index > -1) {
if (xel.deleteing) {
this.xelements.splice(index, 1)
} else {
xel.deleteing = true
xel.dirty()
}
}
}
}
class Painter {
updateLayerList (xelList: XElement[]) {
// ...
for (let i = 0; i < xelList.length; i += 1) {
let xel = xelList[i]
let layer = layerList[xel.zIndex] || this.createLayer(xel.zIndex)
if (xel.deleteing) {
layer._dirty = true
xel.removeSelf()
xelList.splice(i, 1)
i -= 1
continue
}
// ...
}
}
}
hide
同上一个问题,在设置ignored后无法获取到相关元素,也就无从判断是否需要重绘。所以更改这一部分为Stage.getAll能获取到所有元素,而在XElement.refresh中如果其ignored为true,则什么也不做。调用hide时设置dirty。
相应的,在事件检测阶段和组的包围盒计算中应该略过ignored为真的元素。
- 这样处理好像是有点问题。等待后续优化。
Layer.dispose
在绘制阶段即使当前层没有元素关联了,也无需调用Layer.dispose。
BoundingRect.union
现在传入的rect宽高为0的话将被忽略。
小结
从V1至今,XRender,已经有了7个版本,虽然还有种种不完善的地方比如比如渐变,比如图案填充,比如高倍屏下的模糊处理,比如XElement类的代码太多,可以做适当拆分,再比如zrender更重磅的功能——svg渲染,都没有做。但是可以说一个canvas库该有的功能和结构,它都已经具备了,只是需要完善一些细节以便更好地使用。
一路走来对内容修修补补,有些地方遵循xrender的良好设计,有些地方因为懒或者水平所限,留下了不少的瑕疵。不管怎么说,收获了很多。而关于XRender,关于canvas,到此就已经结束了。而从零打造Echarts这一工程却才完成了一小部。接下来,让我们一起进入Echarts的全新篇章吧。
V8预览
待续。