├── README.md ├── Version1.md ├── Version2.md ├── Version3.md ├── Version4.md ├── Version4_1.md ├── Version5.md ├── Version6.1.md ├── Version6.md ├── Version7.md ├── images ├── other │ ├── echarts.png │ ├── echarts_1.png │ └── readme │ │ ├── end.png │ │ ├── home.png │ │ ├── readme_1.gif │ │ ├── readme_2.gif │ │ ├── readme_3.gif │ │ ├── readme_4.gif │ │ ├── readme_5.gif │ │ └── readme_6.gif ├── v2 │ ├── v1.png │ └── v2_1.png ├── v3 │ └── v3_easing.png ├── v4 │ ├── v4_1.png │ ├── v4_2.png │ ├── v4_3.gif │ ├── v4_4.png │ ├── v4_rect_scale1.png │ ├── v4_rect_scale2.png │ └── v4_rect_scale_correct.png ├── v5 │ ├── v5_1.png │ ├── v5_2.png │ ├── v5_3.png │ ├── v5_4.png │ ├── v5_6.png │ ├── v5_7.gif │ └── v5_8.gif ├── v6 │ ├── arc-boudingrect.png │ ├── arc-fill-rule.png │ ├── arc-fill.png │ ├── arc-fill_1.png │ ├── arc-stroke.png │ ├── box.png │ ├── circle-stroke.png │ ├── circle.png │ ├── cursor-pointer.gif │ ├── curve-fill.png │ ├── curve.png │ ├── curve_stroke.png │ ├── drag-el.gif │ ├── drag-group.gif │ ├── line-fill.png │ ├── line-fill_1.png │ ├── line-fill_2.png │ ├── line-stroke.png │ ├── nozero_1.png │ ├── nozero_2.png │ ├── nozero_3.png │ ├── nozero_4.png │ ├── nozero_5.png │ ├── rect-point.png │ └── transform.png └── v7 │ ├── bottom-white.png │ ├── clip-text.png │ ├── clip.png │ ├── font-data.png │ ├── font-height-1.png │ ├── font-height-2.png │ ├── text-aria.png │ ├── text-baseline.png │ ├── text-baselint1.png │ ├── text-ellipsis.png │ ├── text-padding-boudingrect.png │ ├── text-wrap-1.png │ ├── text-wrap-2.png │ ├── text-wrap-3.png │ ├── text-wrap-padding-1.png │ ├── text-wrap-padding-2.png │ ├── text-wrap-padding-3.png │ ├── text-wrap-padding-4-visible.png │ ├── text-wrap.png │ └── top-bottom-white.png ├── math ├── v4 │ ├── math.md │ ├── 公式_1.png │ └── 公式_2.png └── v6 │ ├── math.md │ ├── 公式_1.png │ ├── 公式_2.png │ ├── 公式_3.png │ ├── 公式_4.png │ ├── 公式_5.png │ ├── 公式_6.png │ └── 公式_7.png ├── x_README.md └── x_Version1.md /README.md: -------------------------------------------------------------------------------- 1 | 从3.11日开始换了公司,然后一直在学习英语和加强基础知识,本仓库后续应该会暂停了。期待再见面。 2019-04-15 2 | # 从零打造Echarts ——序言 3 | 我是一个标题党,也不是。 4 | ## 前言 5 | 不知道有没有童鞋和我一样,学习了`canvas`基础用法之后只能画几个图形和文字,对如何实现想要的动画仍然无从下手,看着别人家`canvas`酷炫的效果只能徒生艳羡。而我接触`Echarts`后更是想知道其如何做到的。几番搜索只能找到如何实现粒子动画这样的教程,或者介绍一些流行的`canvas`库和其用法,离实现`echarts`以及其它`canvas`库或引擎相去甚远,没有诸如**自己实现vue**、**自己打造react**这样的东西。而搜索`Echarts`源码解析也结果寥寥。我想了想可能有这样几个原因。 6 | - 和`react`等解析不同,`canvas`库解析需要用到较多的数学或图形学知识,有的比较复杂,而对于一个图标库更是如此,文章写起来费时费力看的人也少。 7 | - 对此感兴趣的人可能并不多。 8 | - 我不会搜索。 9 | - 待定。 10 | 11 | 总之,在这方面,前人提供的经验和教学,我能找到的比较少,只有自己去尝试阅读,并写下这些笔记以便加深理解和回忆,如果能对你有所帮助,就更好了! 12 | ## 目的 13 | 但是本文并不是`echarts`源码解析,而是而是尝试理解并照着`echarts`的设计过程,自己实现一个简易版的`echarts`,即自己的可视化库。 14 | ### 最终目标 15 | - 根据数据输入生成对应的图表,当然只会实现几种,并保留可扩展性。 16 | - 实现`tootip`等基础功能。 17 | - 可个性化。 18 | - 有动画。 19 | ### 但是 20 | 虽然目标看起来还算简单,也许不用深入`echarts`源码,自己瞎写也能实现。但是这只是代码要实现的目标,而不是我,或者作为本文读者的你的目标。我的目的是深入理解`canvas`库的实现原理以及其软件架构,熟悉`canvas`和涉及到的多方面知识,做到知其然也知其所以然,更能自己随便然。 21 | ## 适合读者 22 | - 已有`js`和`canvas`基础。 23 | - 懂一点点`ts`即可,我也不太会,只是为了方便类型提示,所以很多地方写得并不规范,且个人认为不影响阅读。 24 | - 对`canvas`库和细节感兴趣。 25 | - 不浮躁愿意慢慢探究。 26 | - 待定。 27 | ## 关于我 28 | 能看到这里说明你对本文有一点兴趣,在继续下去之前想先说一下我的情况,避免你看了之后大呼上当。 29 | - 前端码农。 30 | - 非计算机专业,计算机基础不佳,更别提图形学了。 31 | - 18年毕业,工作经验不多,接触的项目也不大,但是类型挺多的。 32 | - 前端相关技能和知识掌握程度还可以,杂七杂八的都知道一些。 33 | - 开源贡献暂无,源码阅读方面只深入了解过`vue`。参考[这里](https://github.com/webbillion/vue-notes)。 34 | - 写作本文时还没开始看`echarts`源码。 35 | 36 | 以上就是我的基础情况,如果你愿意尝试从一个半吊子笔下发现一些闪光点,欢迎继续。 37 | ## 那么 38 | [开始吧!](./Version1.md) 39 | -------------------------------------------------------------------------------- /Version1.md: -------------------------------------------------------------------------------- 1 | # 从零打造Echarts —— v1 ZRender和MVC 2 | 本篇开始进入正文。 3 | ## 写在前面 4 | - 图形、元素、图形元素,都指的是`XElement`,看情况哪个顺口用哪个。 5 | - `ts`可能会报警告,我只是想用代码提示功能而已,就不管辣么多了。 6 | - 文内并没有贴出所有代码,且随着版本更迭,可能有修改不及时导致文内代码和源码不一致的情况,可以参考源码进行查看。 7 | - 源码查看的方式,源码放在[这里](https://github.com/webbillion/xrender-src),每一个版本都有对应的分支。 8 | - 由于水平所限,以及后续设计的变更,无法在最开始的版本中就写出最优的代码,甚至可能还会存在一些问题,如果遇到你认为不应该这样写的代码请先不要着急。 9 | ## zrender 10 | `zrender`是`echarts`使用的`2d`渲染器,意思是,对于`2d`图表,`echarts`更多的是对于数据的处理,将数据绘制到`canvas`上这一步是由`zrender`来完成的。 11 | 12 | 大概流程就是,使用者告诉`echarts`我要画条形图,有十条数据,`echarts`计算出条形图高度和坐标和使用`zrender`在画布上绘制坐标轴和十个矩形。它也是`echarts`唯一的依赖。它是一个轻量的二维绘图引擎,但是实现了很多功能。本文就从实现`zrender`开始作为实现`echarts`的第一步。 13 | ## 本篇目标 14 | 前文说到,打造`echarts`从打造一个`zrender`开始,但是`zrender`的功能同样很多,不可能一步到位,所以先从最基础的功能开始,而我们的库我给它命名为`XRender`,即无限可能的渲染器。本篇结束后它将实现`zrender`的以下功能。 15 | ```javascript 16 | import * as xrender from '../xrender' 17 | 18 | let xr = xrender.init('#app') 19 | let circle = new xrender.Circle({ 20 | shape: { 21 | cx: 40, 22 | cy: 40, 23 | r: 20 24 | } 25 | }) 26 | xr.add(circle) 27 | // 现在画布上有一个半径为20的圆了 28 | ``` 29 | ## 正文 30 | ### 模式 31 | 首先明确一点,我们根据数据来实现视图。 32 | 33 | 然后看看我们需要哪些东西来实现我们要的功能。 34 | - 要绘制的元素,如圆、长方形, 即`Element`,为了和`html`中区分,暂命名为`XElment`。 35 | - 因为会有多个元素,我们需要对其进行增查删改等管理,类似于`3d`游戏开发中常见的`scene`(场景),这里叫做`Stage`,舞台。`zrender`中叫做`Storage`。都差不多。 36 | - 需要将舞台上的元素绘制到画布上,叫做`Paniter`。 37 | - 最终需要将上面的三者关联起来,即`XRender`。 38 | 39 | 也就是`MV`模式。 40 | 41 | 考虑到会有多种图形,所以`xrender`最终导出的是一个命名空间,遵循`zrender`的设计,并不向外暴露`XRender`类。那么接下来就可以开始写代码了。 42 | ### 环境搭建 43 | 为了方便,我使用了`vue-cli`搭建环境,你也可以用其它方式,只要能支持出现的语法就行。接着创建`xrender`目录。或者克隆仓库一键安装。根据上面列出的类,创建如下文件。 44 | ``` sh 45 | index.js # 外部引用的入口 46 | Painter.js 47 | Stage.js 48 | XElement.js 49 | XRender.js 50 | ``` 51 | 但是需要做一点小小的修正,因为`XElement`应该是一个抽象类,它只代表一个元素,它本身不提供任何绘制方法,提供绘制方法的应该是继承它的圆`Circle`类。所以修改后的目录如下。 52 | ``` sh 53 | │ index.js 54 | │ Painter.js 55 | │ Stage.js 56 | │ XRender.js 57 | │ 58 | └─xElements 59 | Circle.js 60 | XElement.js 61 | ``` 62 | 接着在每个文件内创建对应的类,并让构造函数打印出当前类的名称,然后导出,以便搭建整体架构。如: 63 | ```javascript 64 | class Stage { 65 | constructor () { 66 | console.log('Stage') 67 | } 68 | } 69 | 70 | export default Stage 71 | 72 | ``` 73 | 然后编写`index.js` 74 | ```javascript 75 | import XRedner from './XRender' 76 | // 导出具体的元素类 77 | export { default as Circle } from './xElements/Circle' 78 | // 只暴露方法而不直接暴露`XRender`类 79 | export function init () { 80 | return new XRedner() 81 | } 82 | 83 | ``` 84 | 在使用它之前我们还得为`XRender`类添加`add`方法,尽管现在它什么都没做。 85 | ```javascript 86 | // 尽管没有使用,但是需要用它来做类型提示 87 | // 用Flow和ts,或jsdoc等,都嫌麻烦 88 | import XElement from "./xElements/XElement"; 89 | 90 | class XRender { 91 | /** 92 | * 93 | * @param {XElement} xel 94 | */ 95 | add (xel) { 96 | console.log('add an el') 97 | } 98 | } 99 | ``` 100 | 接下来就可以在`App.vue`中写最开始的代码。如果一切顺利,应该能在控制台上看到 101 | ``` sh 102 | XRender 103 | Circle 104 | add an el 105 | ``` 106 | ## 细节填充 107 | 在下一步之前,我们可能需要一些辅助函数,比如我们经常会判断某个参数是不是字符串。为此我们创建`util`文件夹来存放辅助函数。 108 | ### XElement 109 | 图形元素,一个抽象类,它应该帮继承它的类如`Circle`处理好样式之类的选项,`Circle`只需要绘制即可。显然它的构造函数应该接受一个选项作为参数,包括这些: 110 | ```typescript 111 | import { merge } from '../util' 112 | /** 113 | * 目前什么都没有 114 | */ 115 | export interface XElementShape { 116 | } 117 | /** 118 | * 颜色 119 | */ 120 | type Color = String | CanvasGradient | CanvasPattern 121 | export interface XElementStyle { 122 | // 先只设定描边颜色和填充 123 | /** 124 | * 填充 125 | */ 126 | fill?: Color 127 | /** 128 | * 描边 129 | */ 130 | stroke?: Color 131 | } 132 | /** 133 | * 元素选项接口 134 | */ 135 | interface XElementOptions { 136 | /** 137 | * 元素类型 138 | */ 139 | type?: string 140 | /** 141 | * 形状 142 | */ 143 | shape?: XElementShape 144 | /** 145 | * 样式 146 | */ 147 | style?: XElementStyle 148 | } 149 | ``` 150 | 接着是对类的设计,对于所有选项,它应该有一个默认值,然后在更新时被覆盖。 151 | ```typescript 152 | class XElement { 153 | shape: XElementShape = {} 154 | style: XElementStyle = {} 155 | constructor (opt: XElementOptions) { 156 | this.options = opt 157 | } 158 | /** 159 | * 这一步不在构造函数内进行是因为放在构造函数内的话,会被子类的默认属性声明重写 160 | */ 161 | updateOptions () { 162 | let opt = this.options 163 | if (opt.shape) { 164 | // 这个函数会覆盖第一个参数中原来的值 165 | merge(this.shape, opt.shape) 166 | } 167 | if (opt.style) { 168 | merge(this.style, opt.style) 169 | } 170 | } 171 | } 172 | ``` 173 | 对于一个元素,应该提供一个绘制方法,正如上面所提到的,这由它的子类提供。此外在绘制之前还需要对样式进行处理,绘制之后进行还原。而这就需要一个`canvas`的`context`。这里认为它由外部提供。涉及到的`api`请自行查阅。 174 | ```typescript 175 | class XElement { 176 | /** 177 | * 绘制 178 | */ 179 | render (ctx: CanvasRenderingContext2D) { 180 | 181 | } 182 | /** 183 | * 绘制之前进行样式的处理 184 | */ 185 | beforeRender (ctx: CanvasRenderingContext2D) { 186 | this.updateOptions() 187 | let style = this.style 188 | ctx.save() 189 | ctx.fillStyle = style.fill 190 | ctx.strokeStyle = style.stroke 191 | ctx.beginPath() 192 | } 193 | /** 194 | * 绘制之后进行还原 195 | */ 196 | afterRender (ctx: CanvasRenderingContext2D) { 197 | ctx.stroke() 198 | ctx.fill() 199 | ctx.restore() 200 | } 201 | /** 202 | * 刷新,这个方法由外部调用 203 | */ 204 | refresh (ctx: CanvasRenderingContext2D) { 205 | this.beforeRender(ctx) 206 | this.render(ctx) 207 | this.afterRender(ctx) 208 | } 209 | ``` 210 | 为什么不在创建它的时候传入`ctx`作为属性的一部分?实际上这完全可行。只是`zrender`这样设计,我也暂时先这么做。可能是为了解耦以及多种`ctx`的需要。 211 | ### Circle 212 | 基类`XElement`已经初步构造完毕,接下来就来构造`Circle`,我们只需声明它需要哪些配置,并提供绘制方法即可。也就是,如何绘制一个圆。 213 | ```typescript 214 | import XElement, { XElementShape } from './XElement' 215 | 216 | interface CircleShape extends XElementShape { 217 | /** 218 | * 圆心x坐标 219 | */ 220 | cx: number 221 | /** 222 | * 圆心y坐标 223 | */ 224 | cy: number 225 | /** 226 | * 半径 227 | */ 228 | r: number 229 | } 230 | interface CircleOptions extends XElementOptions { 231 | shape: CircleShape 232 | } 233 | 234 | class Circle extends XElement { 235 | name ='circle' 236 | shape: CircleShape = { 237 | cx: 0, 238 | cy: 0, 239 | r: 100 240 | } 241 | constructor (opt: CircleOptions) { 242 | super(opt) 243 | } 244 | render (ctx: CanvasRenderingContext2D) { 245 | let shape = this.shape 246 | ctx.arc(shape.cx, shape.cy, shape.r, 0, Math.PI * 2, true) 247 | } 248 | } 249 | 250 | export default Circle 251 | ``` 252 | 来验证一下吧,在`App.vue`中加入如下代码: 253 | ```typescript 254 | mounted () { 255 | let canvas = document.querySelector('#canvas') as HTMLCanvasElement 256 | let ctx = canvas.getContext('2d') as CanvasRenderingContext2D 257 | circle.refresh(ctx) 258 | } 259 | ``` 260 | 查看页面,已经有了一个黑色的圆。 261 | ### Stage 262 | 需要它对元素进行增查删改,很容易写出这样的代码。 263 | ```typescript 264 | class Stage { 265 | /** 266 | * 所有元素的集合 267 | */ 268 | xelements: XElement[] = [] 269 | constructor () { 270 | console.log('Stage') 271 | } 272 | /** 273 | * 添加元素 274 | * 显然可能会添加多个元素 275 | */ 276 | add (...xelements: XElement[]) { 277 | this.xelements.push(...xelements) 278 | } 279 | /** 280 | * 删除指定元素 281 | */ 282 | delete (xel: XElement) { 283 | let index = this.xelements.indexOf(xel) 284 | if (index > -1) { 285 | this.xelements.splice(index) 286 | } 287 | } 288 | /** 289 | * 获取所有元素 290 | */ 291 | getAll () { 292 | return this.xelements 293 | } 294 | } 295 | ``` 296 | ### Painter 297 | 绘画控制器,它将舞台上的元素绘制到画布上,那么创建它时就需要提供一个`Stage`和画布——当然,库的通用做法是也可以提供一个容器,由库来创建画布。 298 | 299 | ```typescript 300 | /** 301 | * 创建canvas 302 | */ 303 | function createCanvas (dom: string | HTMLCanvasElement | HTMLElement) { 304 | if (isString(dom)) { 305 | dom = document.querySelector(dom as string) as HTMLElement 306 | } 307 | if (dom instanceof HTMLCanvasElement) { 308 | return dom 309 | } 310 | let canvas = document.createElement('canvas'); 311 | (dom).appendChild(canvas) 312 | 313 | return canvas 314 | } 315 | 316 | class Painter { 317 | canvas: HTMLCanvasElement 318 | stage: Stage 319 | ctx: CanvasRenderingContext2D 320 | constructor (dom: string | HTMLCanvasElement | HTMLElement, stage: Stage) { 321 | this.canvas = createCanvas(dom) 322 | this.stage = stage 323 | this.ctx = this.canvas.getContext('2d') 324 | } 325 | } 326 | ``` 327 | 它应该实现一个`render`方法,遍历`stage`中的元素进行绘制。 328 | ```typescript 329 | render () { 330 | let xelements = this.stage.getAll() 331 | for (let i = 0; i < xelements.length; i += 1) { 332 | xelements[i].refresh(this.ctx) 333 | } 334 | } 335 | ``` 336 | ### XRender 337 | 最后一步啦,创建`XRender`将它们关联起来。这很简单。 338 | ```typescript 339 | import XElement from './xElements/XElement' 340 | import Stage from './Stage' 341 | import Painter from './Painter' 342 | 343 | class XRender { 344 | stage: Stage 345 | painter: Painter 346 | constructor (dom: string | HTMLElement) { 347 | let stage = new Stage() 348 | this.stage = stage 349 | this.painter = new Painter(dom, stage) 350 | } 351 | add (...xelements: XElement[]) { 352 | this.stage.add(...xelements) 353 | this.render() 354 | } 355 | render () { 356 | this.painter.render() 357 | } 358 | } 359 | ``` 360 | 现在去掉之前试验`Circle`的代码,保存之后可以看见,仍然绘制出了一个圆,这说明成功啦! 361 | 362 | 让我们再多添加几个圆试一下,并传入不同的参数。 363 | ```typescript 364 | let xr = xrender.init('#app') 365 | let circle = new xrender.Circle({ 366 | shape: { 367 | cx: 40, 368 | cy: 40, 369 | r: 20 370 | } 371 | }) 372 | let circle1 = new xrender.Circle({ 373 | shape: { 374 | cx: 60, 375 | cy: 60, 376 | r: 20 377 | }, 378 | style: { 379 | fill: '#00f' 380 | } 381 | }) 382 | let circle2 = new xrender.Circle({ 383 | shape: { 384 | cx: 100, 385 | cy: 100, 386 | r: 40 387 | }, 388 | style: { 389 | fill: '#0ff', 390 | stroke: '#f00' 391 | } 392 | }) 393 | xr.add(circle, circle1, circle2) 394 | ``` 395 | 可以看到屏幕上出现了3个圆。接下来我们再尝试扩展一个矩形。 396 | ```typescript 397 | interface RectShape extends XElementShape { 398 | /** 399 | * 左上角x 400 | */ 401 | x: number 402 | /** 403 | * 左上角y 404 | */ 405 | y: number 406 | width: number 407 | height: number 408 | } 409 | interface RectOptions extends XElementOptions { 410 | shape: RectShape 411 | } 412 | 413 | class Rect extends XElement { 414 | name ='rect' 415 | shape: RectShape = { 416 | x: 0, 417 | y: 0, 418 | width: 0, 419 | height: 0 420 | } 421 | constructor (opt: RectOptions) { 422 | super(opt) 423 | } 424 | render (ctx: CanvasRenderingContext2D) { 425 | let shape = this.shape 426 | ctx.rect(shape.x, shape.y, shape.width, shape.height) 427 | } 428 | } 429 | ``` 430 | 然后在`App.vue`中添加代码: 431 | ```typescript 432 | let rect = new xrender.Rect({ 433 | shape: { 434 | x: 120, 435 | y: 120, 436 | width: 40, 437 | height: 40 438 | }, 439 | style: { 440 | fill: 'transparent' 441 | } 442 | }) 443 | xr.add(rect) 444 | ``` 445 | 可以看到矩形出现了。 446 | 447 | ## 小结 448 | 虽然还有很多问题,比如样式规则不完善,比如多次调用`add`会有不必要的重绘;实现添加圆和矩形这样的功能搞得如此复杂看起来也有点不必要。但是我们已经把基础的框架搭建好了,接下来相信可以逐步完善,最终达成我们想要的效果。 449 | ## V2预览 450 | [下个版本](./Version2.md)中除了解决小结中出现的两个问题外,还将实现图形分层的功能,即指定图形的层叠顺序。 -------------------------------------------------------------------------------- /Version2.md: -------------------------------------------------------------------------------- 1 | # 从零打造Echarts —— V2 属性更新和样式解析 2 | 本文开始v2版本(直到`xrender`初步完成为止,标题均指`xrender`的版本和特性)。 3 | ## 回顾V1 4 | 在上个版本中,我们实现了`zrender`官方文档首页的代码示例。不过还有一些问题等待我们解决。 5 | ## 样式规则 6 | 运行之前的代码可以看到其结果如下。 7 | 8 | ![v1图片](./images/v2/v1.png) 9 | 10 | - 没有指定填充颜色的图形会被填充为黑色,描边同理。就我个人理解而言,对于一个图形,通常需要默认描边,而填充则不需要。 11 | - 需要更多的样式规则应用,如透明度,线宽。 12 | 13 | 回到`XElement.ts`中,显然随着样式越来越复杂,需要将样式处理抽离为一个函数。 14 | ```typescript 15 | /** 16 | * 将指定样式绑定到上下文中 17 | */ 18 | function bindStyle (ctx: CanvasRenderingContext2D, style: XElementStyle) { 19 | let fill = style.fill || 'transparent' 20 | ctx.fillStyle = fill 21 | ctx.strokeStyle = style.stroke 22 | ctx.globalAlpha = style.opacity 23 | ctx.lineWidth = style.lineWidth 24 | } 25 | ``` 26 | 然后在之前的`beforeRender`中应用即可。之后可以看到没有指定填充的图形不再有填充(更多的样式则需要依次处理,后面用到了再添加)。 27 | ## ~~高倍屏~~ 28 | ~~但是看着看着就觉得有什么不对,怎么这么模糊呢?这牵扯到设备独立像素和绘制像素等东西,和图片在高倍屏下的问题是一样的,不明白的可以自行搜索。解决方式和`1px`边框类似,找到渲染倍率,绘制的时候数据乘以这个倍率,然后将画布缩放为一倍大小即可。~~ 29 | ## 添加元素后重绘频率 30 | 在之前的代码中,如果连续调用`add`,会连续触发`render`,这种情况下应该将其合并为一次。使用防抖函数,很容易做到这一点。 31 | 32 | 在`util.ts`中添加防抖函数。 33 | ```typescript 34 | export function debounce (fn: Function, delay = 300) { 35 | let timer = null 36 | return function (...args) { 37 | clearTimeout(timer) 38 | timer = setTimeout(() => { 39 | fn(...args) 40 | }, delay) 41 | } 42 | } 43 | ``` 44 | 问题是要在哪里加入防抖?是`XRender`中,还是`Painter中`?我暂时决定加入到`Painter`,间隔控制在一帧`16ms`。修改`Painter.render` 45 | ```typescript 46 | class Painter { 47 | render = debounce(() => { 48 | let xelements = this.stage.getAll() 49 | for (let i = 0; i < xelements.length; i += 1) { 50 | xelements[i].refresh(this.ctx) 51 | } 52 | }, 16) 53 | } 54 | ``` 55 | ## 层级 56 | 在之前的代码中,如果两个图形有区域重合,后添加的图形会覆盖之前的图形,而实际使用中往往需要指定图形在最上层,只需要引入层级`zLevel`即可做到这一点。编辑`XElement.ts`。 57 | ```typescript 58 | export interface XElementOptions { 59 | /** 60 | * 元素所处层级 61 | */ 62 | zLevel?: number 63 | } 64 | class XElement { 65 | updateOptions () { 66 | // ... 67 | // 不考虑它为0 68 | if (opt.zLevel) { 69 | this.zLevel = opt.zLevel 70 | } 71 | } 72 | } 73 | ``` 74 | 然后在`Stage.getAll`中对所有元素进行排序。 75 | ```typescript 76 | class Stage { 77 | /** 78 | * 获取所有元素 79 | */ 80 | getAll () { 81 | this.updateXElements() 82 | 83 | return this.xelements 84 | } 85 | updateXElements () { 86 | // zLevel高的在后,其它按加入次序排列 87 | this.xelements.sort((a, b) => { 88 | return a.zLevel - b.zLevel 89 | }) 90 | } 91 | } 92 | ``` 93 | `App.vue`中为第一个圆加入`zLevel: 2`的参数,可以看到它不再被第二个圆遮挡(为了明显区分,加了fill参数)。 94 | 95 | ![不被遮挡](./images/v2/v2_1.png) 96 | ## 更多选项 97 | 之前对于`XRender`的设计显然太过简单,现在试着添加一些参数,比如指定画布的宽高和背景色——当然,它会被传递给`Painter`。所以此处只展示`Painter.ts`内的代码。 98 | ```typescript 99 | function createCanvas (dom: string | HTMLCanvasElement | HTMLElement) { 100 | // ... 101 | let canvas = document.createElement('canvas'); 102 | (dom).appendChild(canvas) 103 | 104 | canvas.height = (dom).clientHeight 105 | canvas.width = (dom).clientWidth 106 | 107 | return canvas 108 | } 109 | /** 110 | * 后续还有更多的样式需要设置 111 | */ 112 | function setCanvasStyle (canvas: HTMLCanvasElement, opt: PainterOptions) { 113 | if (opt.height) { 114 | canvas.height = opt.height 115 | canvas.style.height = `${opt.height}px` 116 | } else { 117 | opt.height = canvas.clientHeight 118 | } 119 | if (opt.width) { 120 | canvas.width = opt.width 121 | canvas.style.width = `${opt.width}px` 122 | } else { 123 | opt.width = canvas.clientWidth 124 | } 125 | if (opt.backgroundColor) { 126 | canvas.style.backgroundColor = opt.backgroundColor 127 | } 128 | class Painter { 129 | constructor (dom: string | HTMLCanvasElement | HTMLElement, stage: Stage, opt: PainterOptions) { 130 | this.opt = opt 131 | this.canvas = createCanvas(dom) 132 | setCanvasStyle(this.canvas, opt) 133 | // ... 134 | } 135 | } 136 | ``` 137 | ## 属性更新 138 | 显然我们是需要对已创建的元素进行属性的更新的——不然无法实现动画,更新属性之后则需要重新渲染,但是我们也知道无法只对单个元素重绘,只能全部重新绘制(clip方法虽然能限定绘制区域,但是这并没有本质区别)。能否在绘制元素时给定清除方法,更新单个元素时调用清除方法,再重新绘制呢?事实上这是可以的,不过除了让事情变得更复杂以外,没有多余的好处,还会让层级关系变得一团糟。 139 | > 以上为写作本文时的个人理解,仅供参考。 140 | 141 | 而此前的代码中元素不知道掌控全局渲染的`Painter`或者说`XRender`,也就无法触发,需要将其关联起来——显然不能在构造函数中传入,得在另外的时机注入,回想一下代码过程,它们是在`add`方法中产生交集的,那么可以这样做。 142 | ```typescript 143 | class XElement { 144 | /** 145 | * 设置元素相关的`xr` 146 | */ 147 | setXr (xr: XRender) { 148 | this._xr = xr 149 | } 150 | } 151 | class XRender { 152 | add (...xelements: XElement[]) { 153 | xelements.forEach(xel => { 154 | xel.setXr(this) 155 | }) 156 | // ... 157 | } 158 | } 159 | ``` 160 | 然后编写更新属性的方法,更新完毕后调用`xr.render`即可。更新属性(我们命名为`attr`)分为两种情况,一如要更新层级,`attr('zLevel', 1)`;一如要更新某一个样式或形状,可以有两种实现方式,如`attr('style', { fill: '#f00' })`,以及`attr('style.fill', '#f00')`,考虑到很可能会一次更新多个属性,这里选择第一种——然后更新层级也可以这样做`attr({ zLevel: 1 })`。实现过程很简单,这里只贴出代码。 161 | ```typescript 162 | class XElement { 163 | /** 164 | * 到后面会发现,对不同的属性,需要有不同的设置方法 165 | */ 166 | attrFunctions = { 167 | shape: (newShape: Object) => { 168 | let shape = this.options.shape 169 | merge(shape, newShape) 170 | }, 171 | style: (newStyle) => { 172 | let style = this.options.style 173 | merge(style, newStyle) 174 | } 175 | } 176 | /** 177 | * 实际设置属性的方法 178 | */ 179 | attrKv (key: string, value: any) { 180 | let updateMethod = this.attrFunctions[key] 181 | if (updateMethod) { 182 | updateMethod(value) 183 | } else { 184 | this.options[key] = value 185 | } 186 | } 187 | /** 188 | * 更新属性并重绘 189 | */ 190 | attr (key: String | Object, value?: any) { 191 | if (isString(key)) { 192 | this.attrKv(key as string, value) 193 | } else if (isObject(key)) { 194 | for (let name in key) { 195 | if (key.hasOwnProperty(name)) { 196 | this.attrKv(name, key[name]) 197 | } 198 | } 199 | } 200 | this.updateOptions() 201 | this._xr.render() 202 | } 203 | } 204 | ``` 205 | 在`App.vue`中添加代码`circle.attr({ shape: { r: 60 } })`,发现已经生效。 206 | ## 显示/隐藏 207 | 显示和隐藏一个元素也是常见的功能,实现它只需要为元素添加`ignored`属性并提供`show`和`hide`方法来改变此属性,最后在`Stage.getAll`中过滤掉`ignored`为真的元素即可。 208 | ```typescript 209 | class XElement { 210 | /** 211 | * 为真的话绘制时会忽略此元素 212 | */ 213 | ignored: boolean 214 | /** 215 | * 显示元素 216 | */ 217 | show () { 218 | this.ignored = false 219 | this._xr.render() 220 | } 221 | /** 222 | * 隐藏元素 223 | */ 224 | hide () { 225 | this.ignored = true 226 | this._xr.render() 227 | } 228 | } 229 | class Stage {/** 230 | * 获取所有元素 231 | */ 232 | getAll () { 233 | this.updateXElements() 234 | return this.xelements.filter(xel => !xel.ignored) 235 | } 236 | } 237 | ``` 238 | 试验一下`circle.hide()`,(⊙o⊙)…好像没什么效果,想了想,在每次更新前应该清空画布才对。 239 | ```typescript 240 | class Painter { 241 | render = debounce(() => { 242 | this.beforeRender() 243 | //... 244 | }) 245 | beforeRender () { 246 | this.ctx.clearRect(0, 0, this.opt.width, this.opt.height) 247 | } 248 | } 249 | ``` 250 | 保存,ok。 251 | ## 小结 252 | 本版本的新功能并不多,可以说是对`v1`的一点补充。但是它也是实现下个功能不可缺少的一部分。 253 | ## V3预览 254 | [动画](./Version3.md)。 -------------------------------------------------------------------------------- /Version3.md: -------------------------------------------------------------------------------- 1 | # 从零打造Echarts —— V3 动画 2 | 本文开始v3版本。 3 | ## 回顾v2 4 | v2版本我们在v1的基础上添加了诸如属性更新、隐藏元素这样的功能。而其中的属性更新,就是v3版本要做的动画功能的基础。 5 | ## 目标 6 | 实现元素的动画功能。 7 | ## 开始 8 | 假设现在的需求是在1s内让圆的半径匀速地(**后面的示例除非特别说明,全部指匀速,即线性**)从原来的20变为40。很容易写出这样的代码。 9 | ```typescript 10 | let targetR = 40 11 | let currentR = 20 12 | let deltaR = targetR - currentR 13 | let time = 1000 14 | let preUpdateTime = Date.now() 15 | let update = () => { 16 | let now = Date.now() 17 | let r = currentR + (now - preUpdateTime) / time * deltaR 18 | // 以免超出 19 | if (r > targetR) { 20 | r = targetR 21 | } 22 | circle.attr({ 23 | shape: { 24 | r 25 | } 26 | }) 27 | if (r !== targetR) { 28 | requestAnimationFrame(update) 29 | } 30 | } 31 | requestAnimationFrame(update) 32 | ``` 33 | 等等,好像有什么不对,这个动画怎么这么卡顿!第一反应是上面的代码存在帧率和计算的问题(关于帧率以后会涉及到),第二反应则是之前对于`render`的防抖出了问题。将对`render`的防抖间隔从`16ms`改为`14ms`后动画效果得到显著改善,暂且设置为`10ms`吧,以后有不流畅再另行调整。 34 | 35 | 这样的代码明显是不符合使用需求的,我们更想这样使用。 36 | ```typescript 37 | circle.animateTo({ 38 | shape: { 39 | r: 40 40 | } 41 | }, 1000) 42 | ``` 43 | 有了上面的代码做为启示,相信你对如何做到这一点已经有了一些思路。 44 | - **动画原理** 从上面的代码可以看出,对于`xrender`的元素,动画的核心原理就是不断更新属性,不断重新绘制。我们要做的就是计算出过渡状态。 45 | - **分离动画和元素** 容易想到的一点是,动画的实现过程是和元素无关的,可以单独作为一个模块——实际上这样的模块很多,如`Tween.js`,但是我们并不打算使用现成的库——不是为了实现功能而开发。然后再将动画和元素结合起来。 46 | 47 | 下面让我们一起把思路实现吧。 48 | ## Animation 49 | 创建`Animation.ts`文件。构造并导出一个`Animation`类。和其它动画库一样,我准备这样设(fu)计(zhi),它应该实现如下的功能,且可以链式调用,命名方面和`zrender`保持一致。 50 | - 接受一个对象做参数并返回一个实例。`new Animation({ x: 100 })`。 51 | - `when`,设置关键帧,`animation.when(500, { x: 200 })`,意思是在`500ms`时变为`200`。 52 | - `during`,设置每一帧更新时的回调,`animation.during(() => { console.log('我更新啦') })`,它将在每一帧更新都打印。 53 | - `delay`,设置动画的延迟时间,`animation.delay(500)`,在`500ms`后才开始执行动画。 54 | - `done`,设置动画完成后的回调。 55 | - `start`,上面的函数调用完后并不会开始执行动画,需要显示调用`animation.start`来开始动画,如果设置了延迟,那么延迟相应时间后开始。 56 | - `stop`,中途可以停止动画。 57 | - `pause`,暂停动画。 58 | 59 | 如何做到上述功能?以 60 | ```typescript 61 | new Animation({ x: 100 }) 62 | .when(500, { x: 200 }) 63 | .during(() => { console.log('我更新啦')}) 64 | .start() 65 | ``` 66 | 为例,可以这样思考 67 | - `when`,在内部维护一个动画队列,每次调用`when`方法就解析传入的参数为标准格式,放入队列中,但它只保存了关键帧的数据,而没有计算出过度状态,因为过度状态是需要每一帧更新按时间来计算的,而不是事先计算好,每一帧更新时直接使用。就像这样。 68 | ``` typescript 69 | queue = { 70 | x: [ 71 | { 72 | time: 0, value: 100 73 | }, 74 | { 75 | time: 500, value: 200 76 | } 77 | ] 78 | } 79 | ``` 80 | **另外一点需要注意的是,为了简便,对于非数值的属性将会略过,颜色转换请自行探索。** 81 | - `done`、`during`,同样维护对应回调队列,更新和结束时一一调用即可。 82 | - `start`, 调用`requestAnimation`执行更新函数。如 83 | - `update`, 遍历动画队列,计算差值。假设现在的时间是开始动画后第一帧,第`16ms`,`x`属性的队列`time`大于等于`16`且最近的是`{ time: 500, value: 200 } }`,小于等于`16`最近的是`{ time: 0, value: 100 }`,则`x`应该为`100 + 16 / (500 - 0) * (200 - 100)` 103.2。 84 | 85 | 大概流程就是如上所示,来开始编写代码吧。首先是`constructor`和`when`。 86 | ```typescript 87 | /** 88 | * 关键帧队列格式 89 | */ 90 | export interface KeyFrameInQueue { 91 | /** 92 | * 时间 93 | */ 94 | time: number 95 | /** 96 | * 值 97 | */ 98 | value: number 99 | } 100 | 101 | class Animation { 102 | /** 103 | * 要动画的目标 104 | */ 105 | _target: Object 106 | /** 107 | * 关键帧队列 108 | */ 109 | _keyFrameQueue: { 110 | [prop: string]: KeyFrameInQueue[] 111 | } = {} 112 | /** 113 | * 动画持续的最久的时间,用来判定是否结束 114 | */ 115 | _maxTime = 0 116 | constructor (target: Object) { 117 | // 克隆函数应该不必多说 118 | this._target = clone(target) 119 | } 120 | /** 121 | * 设置关键帧 122 | * @param time 关键帧时间 123 | * @param animateObj 要动画的对象 124 | */ 125 | when (time = 1000, animateObj = {}) { 126 | if (!animateObj || !isObject(animateObj)) { 127 | return 128 | } 129 | let target = this._target 130 | for (let key in animateObj) { 131 | // 如果之前没有这个值,将其设为0 132 | if (!target[key]) { 133 | target[key] = 0 134 | } 135 | let keyQueue = this._keyFrameQueue[key] 136 | if (!keyQueue) { 137 | // 还没有的话初始化为最初的值 138 | keyQueue = this._keyFrameQueue[key] = [ 139 | { 140 | time: 0, 141 | value: target[key] 142 | } 143 | ] 144 | } 145 | let keyFrame = { 146 | time, 147 | value: animateObj[key] 148 | } 149 | // 在此处插入排序 150 | for (let i = (keyQueue.length - 1); i >= 0; i -= 1) { 151 | if (keyQueue[i].time < time) { 152 | keyQueue.splice(i + 1, 0, keyFrame) 153 | break 154 | } 155 | // 如果两个关键帧时间相同,则替换掉 156 | if (keyQueue[i].time === time) { 157 | keyQueue.splice(i, 1, keyFrame) 158 | break 159 | } 160 | // 否则什么也不做 161 | } 162 | if (time > this._maxTime) { 163 | this._maxTime = time 164 | } 165 | 166 | return this 167 | } 168 | } 169 | ``` 170 | 然后是`during`、`done`和`delay`,很简单。 171 | ``` typescript 172 | class Animation { 173 | /** 174 | * 持续触发的回调队列 175 | */ 176 | _duringQueue: ((target: Object) => void)[] = [] 177 | /** 178 | * 完成回调队列 179 | */ 180 | _doneQueue: ((target: Object) => void)[] = [] 181 | /** 182 | * 开始的延迟时间 183 | */ 184 | _delay = 0 185 | /** 186 | * 设置每一帧的回调 187 | */ 188 | during (callback: (target: Object) => void) { 189 | if (!isFunction(callback)) { 190 | return this 191 | } 192 | this._duringQueue.push(callback) 193 | 194 | return this 195 | } 196 | /** 197 | * 设置动画完成的回调 198 | */ 199 | done (callback: (target: Object) => void) { 200 | if (!isFunction(callback)) { 201 | return this 202 | } 203 | this._doneQueue.push(callback) 204 | 205 | return this 206 | } 207 | /** 208 | * 设置动画开始的延迟 209 | */ 210 | delay (delay: number) { 211 | this._delay = delay 212 | 213 | return this 214 | } 215 | } 216 | ``` 217 | 接着来编写`start`。 218 | ```typescript 219 | class Animation { 220 | // ... 221 | /** 222 | * 动画开始的时间 223 | */ 224 | startTime = 0 225 | /** 226 | * 开始动画 227 | */ 228 | start () { 229 | let fn = () => { 230 | this.startTime = Date.now() 231 | requestAnimationFrame(this.update) 232 | } 233 | // 延迟执行 234 | if (this._delay > 0) { 235 | setTimeout(fn, this._delay) 236 | } else { 237 | fn() 238 | } 239 | 240 | return this 241 | } 242 | } 243 | ``` 244 | 流程已经启动了,最后再完善我们的`update`函数。 245 | ```typescript 246 | class Animation { 247 | /** 248 | * 更新 249 | */ 250 | update = () => { 251 | let nowTime = Date.now() 252 | let deltaTime = nowTime - this.startTime 253 | // 遍历关键帧队列 254 | for (let key in this._keyFrameQueue) { 255 | let keyQueue = this._keyFrameQueue[key] 256 | this._target[key] = getCurrentValue(keyQueue, deltaTime) 257 | } 258 | // 更新时的回调 259 | this._duringQueue.forEach(fn => { 260 | fn(this._target) 261 | }) 262 | // 完成时的回调 263 | if (deltaTime >= this._maxTime) { 264 | this._doneQueue.forEach(fn => { 265 | fn(this._target) 266 | }) 267 | // TODO: 做一些重置操作,如把队列清空 268 | } else { 269 | requestAnimationFrame(this.update) 270 | } 271 | } 272 | } 273 | ``` 274 | 最关键的就是`getCurrentValue`函数,它根据队列和变化时间返回一个值,实际上它还应该接受一个参数,缓动函数。 275 | ### 缓动函数 276 | 实际使用我们往往需要更多的动画方式,而不是匀速进行,使用缓动函数是方法之一。关于缓动函数,详细的可以搜索了解,这里简单介绍。缓动函数定义如下: 277 | ```typescript 278 | (t, b, c, d) => value 279 | ``` 280 | 四个参数的分别是 281 | - `t` 当前时间 282 | - `b` 初始值 283 | - `c` 要改变的差值 284 | - `d` 持续时间 285 | 以线性匀速运动为例 286 | ```typescript 287 | function linear (t, b, c, d) { 288 | // 以百分比计算 289 | return c * (t / d) + b 290 | } 291 | ``` 292 | 示意图如下(霍霍,我就是传说的灵魂画手): 293 | 294 | ![](./images/v3/v3_easing.png) 295 | 296 | 其它常见的缓动函数有 297 | - `Quadratic` 二次方缓动。 298 | - `Cubic` 三次方缓动。 299 | - `Bounce` 指数衰减的反弹缓动。 300 | 301 | 等等。 302 | 303 | 每一种函数都有三种方式,分别是: 304 | - `ease-in`,先快后慢。 305 | - `ease-out`, 先快后慢。 306 | - `ease-in-out` 前半段由慢到快,后半段由快到慢。 307 | 308 | 以`Bounce`为例。 309 | ```typescript 310 | const Easing = { 311 | linear (t: number, b: number, c: number, d: number) { 312 | return c * (t / d) + b 313 | }, 314 | 'bounce-in' (t: number, b: number, c: number, d: number) { 315 | return c - Easing['bounce-out'](d - t, 0, c, d) + b 316 | }, 317 | 'bounce-out' (t: number, b: number, c: number, d: number) { 318 | if ((t /= d) < (1 / 2.75)) { 319 | return c * (7.5625 * t * t) + b 320 | } else if (t < (2 / 2.75)) { 321 | return c * (7.5625 * (t -= (1.5 / 2.75)) * t + 0.75) + b 322 | } else if (t < (2.5 / 2.75)) { 323 | return c * (7.5625 * (t -= (2.25 / 2.75)) * t + 0.9375) + b 324 | } else { 325 | return c * (7.5625 * (t -= (2.625 / 2.75)) * t + 0.984375) + b 326 | } 327 | }, 328 | 'bounce-in-out' (t: number, b: number, c: number, d: number) { 329 | if (t < d / 2) { 330 | return Easing['bounce-in'](t * 2, 0, c, d) * 0.5 + b 331 | } else { 332 | return Easing['bounce-out'](t * 2 - d, 0, c, d) * 0.5 + c * 0.5 + b 333 | } 334 | } 335 | } 336 | ``` 337 | 创建`Easing.ts`导出这些函数。更多函数以及具体过程请自行探索,也可据此写出自己想要的缓动函数。 338 | 339 | ### 回到`getCurrentValue` 340 | ```typescript 341 | /** 342 | * 处理值可能存在嵌套的情况 343 | * 同时处理特殊值的情况 344 | * @param preValue 前一帧的值 345 | * @param nextValue 下一帧的值 346 | * @param currentTime 当前时间 347 | * @param duringTime 持续时间 348 | * @param easingFn 缓动函数 349 | */ 350 | function getNestedValue ( 351 | preValue: any, 352 | nextValue: any, 353 | currentTime: number, 354 | duringTime: number, 355 | easingFn: EasingFn 356 | ) { 357 | let value 358 | // 假定前后两次值的类型相同 359 | if (isObject(nextValue)) { 360 | value = {} 361 | for (let key in nextValue) { 362 | value[key] = getNestedValue(preValue[key], nextValue[key], currentTime, duringTime, easingFn) 363 | } 364 | } else { 365 | value = easingFn( 366 | currentTime, 367 | preValue, 368 | nextValue - preValue, 369 | duringTime 370 | ) 371 | } 372 | 373 | return value 374 | } 375 | /** 376 | * 获取当前值 377 | */ 378 | function getCurrentValue ( 379 | queue: KeyFrameInQueue[], 380 | deltaTime: number, 381 | easingFn: EasingFn 382 | ) { 383 | let preFrame: KeyFrameInQueue 384 | let nextFram: KeyFrameInQueue 385 | for (let i = 0; i < queue.length; i += 1) { 386 | let frame = queue[i] 387 | // 已经是最后一帧了,还没有找到前一帧,说明时间已经超过最长关键帧的时间了 388 | // 假定最少有两个关键帧 389 | if (i === queue.length - 1) { 390 | preFrame = queue[i - 1] 391 | nextFram = frame 392 | } else if (frame.time < deltaTime && (queue[i + 1].time >= deltaTime)) { 393 | preFrame = frame 394 | nextFram = queue[i + 1] 395 | break 396 | } 397 | } 398 | let lastFram = queue[queue.length - 1] 399 | // 只有在最后的关键帧中进行时间截断,以免值超出范围 400 | // 也就是说,在回调中应当避免对关键帧设置的值做精确的比较 401 | // 应该有更优的方法,这里暂不讨论 402 | deltaTime = deltaTime > lastFram.time ? lastFram.time : deltaTime 403 | let value = getNestedValue( 404 | preFrame.value, 405 | nextFram.value, 406 | deltaTime - preFrame.time, 407 | nextFram.time - preFrame.time, 408 | easingFn 409 | ) 410 | 411 | return value 412 | } 413 | class Animation { 414 | // ... 415 | update = () => { 416 | // 当然,缓动函数应该是可配置的,但是现在,我们先只传入这一个值 417 | this._target[key] = getCurrentValue(keyQueue, deltaTime, EasingFuns.linear) 418 | } 419 | } 420 | 421 | ``` 422 | 试一下。 423 | ```javascript 424 | new Animation({ x: 100 }) 425 | .during((e) => { 426 | console.log(e) 427 | }) 428 | .when(1000, { x: 200 }) 429 | .when(2000, {x: 100}) 430 | .start() 431 | ``` 432 | 可以看到控制台依次打印出了变化中的值。然后稍微改造本文最开始的代码。 433 | ```typescript 434 | new Animation({ r: 20 }) 435 | .during((shape) => { 436 | circle.attr({ 437 | shape 438 | }) 439 | }) 440 | .when(1000, { r: 40 }) 441 | .start() 442 | ``` 443 | 可以看到效果和之前的一样。 444 | 445 | 最后再做一些收尾工作。 446 | ```typescript 447 | class Animation { 448 | /** 449 | * 动画结束后做收尾工作 450 | */ 451 | resetStatus () { 452 | // 重置开始时间 453 | this.startTime = 0 454 | } 455 | } 456 | ``` 457 | 虽然还有很多细节等待完善,但起码我们让它动起来了不是吗? 458 | 459 | ### 休息一下 460 | 如果你一口气看到这里,你可能需要休息一下再继续接下来的旅程。 461 | ### 继续 462 | 关于`Animation`,就说到这里,它当然还应该有更多功能,比如停止、暂停、循环、循环间隔以及指定缓动函数,这些加一些属性在`update`中判断即可,这里就不详细展开了,可以查看相关代码了解。 463 | ## 整合`XElement` 464 | 现在要做的就是将`Animation`和`XElement`联合起来。容易想到的方式是在创建`XElement`时为它的选项创建一个`Animation`,然后把`animateTo`方法关联到内部的`animation`上。来尝试一下这种方式。 465 | ``` typescript 466 | class XElement { 467 | animation: Animation 468 | /** 469 | * 动画到某个状态 470 | */ 471 | animateTo (target: Object, time: any, delay: any, easing: any, callback: any) { 472 | // 这一段复制的 473 | // animateTo(target, time, easing, callback) 474 | if (isString(delay)) { 475 | callback = easing 476 | easing = delay 477 | delay = 0 478 | // animateTo(target, time, delay, callback) 479 | } else if (isFunction(easing)) { 480 | callback = easing 481 | easing = 'linear' 482 | delay = 0 483 | // animateTo(target, time, callback) 484 | } else if (isFunction(delay)) { 485 | callback = delay 486 | delay = 0 487 | // animateTo(target, callback) 488 | } else if (isFunction(time)) { 489 | callback = time 490 | time = 500 491 | // animateTo(target) 492 | } else if (!time) { 493 | time = 500 494 | } 495 | // 先停止动画 496 | this.animation && this.animation.stop() 497 | this.animation = new Animation({ 498 | shape: this.shape, 499 | style: this.style 500 | }) 501 | 502 | 503 | return this.animation 504 | .during((target) => { 505 | this.attr(target) 506 | }) 507 | .when(time, target) 508 | .done(callback) 509 | .delay(delay) 510 | .start(easing) 511 | } 512 | } 513 | 514 | ``` 515 | 运行本文最开始期望的代码 516 | ```typescript 517 | circle.animateTo({ 518 | shape: { 519 | r: 40 520 | } 521 | }, 1000) 522 | ``` 523 | 完全ok! 524 | 525 | 最后一步比想象中要轻松,也许是因为考虑的东西太简单,但是不管怎么说我们完成了预期的目标。 526 | 527 | 不过,好像还是少了点什么。让我想想,哦,对了,和`css`动画对比一下,现在的动画只能对形状和样式进行修改,少了很关键的`transform`变换,如平移、缩放。 528 | ## v4预览 529 | [`transform`的实现以及其动画](./Version4.md)。 -------------------------------------------------------------------------------- /Version4.md: -------------------------------------------------------------------------------- 1 | # 从零打造Echarts —— V4 平移、旋转和缩放 2 | 本文开始v4版本。 3 | ## 回顾v3 4 | v3版本我们在v2的基础上添加了动画功能,可以实现对样式形状等的更新动画,但是最后发现少了很关键的`transform`动画,本版本就将为我们的`xrender`添加`transform`系统并同样实现它动画。 5 | ## 开始 6 | ### 什么是transform 7 | 就表现而言,是`translate(平移)`、`scale(缩放)`、`rotate(旋转)`、`skew(拉伸)`等效果,而其本质,则是相对坐标系的变换。平移和缩放很好理解,以矩形举例: 8 | 9 | **前方灵魂画手上线,请注意躲避!** 10 | 11 | ![](./images/v4/v4_1.png) 12 | 对该矩形放大(scale)1.5倍,则将其所以点的坐标都乘以1.5,则可得到变换后的矩形(以原点为变换中心点(下面会讲到),下同)。平移同理,加减即可。 13 | 14 | 拉伸则取对应轴角度的`tan`值即可。 15 | 16 | 而旋转则稍微复杂一点,容易想象初始矩形顺时针旋转90度(π / 2)的样子。下图绿色部分。 17 | 18 | ![](./images/v4/v4_2.png) 19 | 点(0, 1)是如何变换到(1, 0)的?很容易看出来 20 | - `x` = `x * cos(π / 2) - y * sin(π / 2)` 21 | - `y` = `y * sin(π / 2) + x * sin(π / 2)` 22 | 23 | ![](./images/v4/v4_3.gif) 24 | 25 | 啊住手!! 26 | 27 | 旋转90度是为了好画,并不有利于证明,以最容易的角度考虑,将`A(x1, y1)`点逆时针旋转`β`度到`A'(x2, y2)`,都在第一象限内,其它情况也差不多。 28 | ![](./images/v4/v4_4.png)。 29 | 30 | **因为有些浏览器不支持公式表达,所以用了图片** 31 | 32 | ![](math/v4/公式_1.png) 33 | 34 | 上述所有变换用矩阵来表示就是 35 | 36 | ![](math/v4/公式_2.png) 37 | 38 | 上面的六个变量,就是`transform`的基础。和`css`中有`matrix`一样,`canvas`提供了这样的`api`——`setTransform`。 39 | 40 | 但是想实现变换却没有这么简单。 41 | 1. 首先我们使用时不可能直接用矩阵去设置,需要将对应的属性转换成矩阵,如`translateX: 20`,转换为`[1, 0, 0, 1, 20, 0]`。 42 | > 不过`canvas`提供了`translate`等方法,我们可以直接使用。 43 | 2. 其次存在多个变换属性时,如同时偏移和旋转,需要将二者经过第一步转换后再叠加计算,而这又牵扯到顺序问题了,是先偏移再旋转还是先旋转再偏移?是提供一个约定的方式,还是提供可选择的配置? 44 | > 同样`canvas`也提供了`transform`方法可以叠加变换。 45 | 46 | 那么上面说辣么多有什么用呢?后续会用到的。 47 | ### 属性设计 48 | 那么一个元素应该有这些`transform`属性。 49 | - `rotation` 旋转角度,和`rotate`方法以示区分。 50 | - `position` 位置,即偏移,一个数组,[`translateX`, `translateY`]。 51 | - `scale` 缩放,同样是一个数组,[`scaleX`, `scaleY`]。 52 | - `origin` 变换中心,缩放和旋转会用到,也是一个数组,[`originX`, `originY`]。 53 | 54 | 编辑`XElement.ts`。 55 | 56 | 首先声明一个`Transform`接口。 57 | ```typescript 58 | interface Transform { 59 | /** 60 | * 位置,即偏移 61 | */ 62 | position: [number, number] 63 | /** 64 | * 缩放 65 | */ 66 | scale: [number, number] 67 | /** 68 | * 旋转 69 | */ 70 | rotation: number 71 | /** 72 | * 变换中心 73 | */ 74 | origin: [number, number] 75 | } 76 | ``` 77 | 然后让`XElement`实现它,并在`updateOptions`中应用传入的选项。 78 | ```typescript 79 | class XElement implements Transform { 80 | // ... 81 | position: [number, number] = [0, 0] 82 | scale: [number, number] = [1, 1] 83 | // 默认以左上角为变换中心,因为无法用百分比————每个图形元素的形状都不相同 84 | origin: [number, number] = [0, 0] 85 | rotation = 0 86 | // ... 87 | updateOptions () { 88 | let opt = this.options 89 | // ... 90 | ['zLevel', 'origin', 'scale', 'position', 'rotation'].forEach(key => { 91 | if (opt[key]) { 92 | this[key] = opt[key] 93 | } 94 | }) 95 | } 96 | } 97 | ``` 98 | 紧接着编写`setTransform`,它将应用变换到上下文中,然后在`beforeRender`中调用它。 99 | ```typescript 100 | class XElement implements Transform { 101 | beforeRender (ctx: CanvasRenderingContext2D) { 102 | // ... 103 | this.setTransform(ctx) 104 | // ... 105 | } 106 | /** 107 | * 设置变换 108 | */ 109 | setTransform (ctx: CanvasRenderingContext2D) { 110 | // 首先变换中心点 111 | ctx.translate(...this.origin) 112 | // 应用缩放 113 | ctx.scale(...this.scale) 114 | // 应用旋转 115 | ctx.rotate(this.rotation) 116 | // 恢复 117 | ctx.translate(-this.origin[0], -this.origin[1]) 118 | // 平移 119 | ctx.translate(...this.position) 120 | } 121 | } 122 | ``` 123 | 尝试一下 124 | ```typescript 125 | let rect = new xrender.Rect({ 126 | shape: { 127 | x: 120, 128 | y: 120, 129 | width: 40, 130 | height: 40 131 | }, 132 | style: { 133 | fill: 'transparent' 134 | }, 135 | origin: [120, 120], 136 | rotation: 0.8 137 | }) 138 | ``` 139 | 可以看到有效果了。 140 | ### 动画 141 | 那么动画呢?都不用尝试,就知道不行,因为之前的动画中没有对值为数组的情况进行处理。 142 | ```typescript 143 | function getNestedValue ( 144 | preValue: any, 145 | nextValue: any, 146 | currentTime: number, 147 | duringTime: number, 148 | easingFn: EasingFn 149 | ) { 150 | let value 151 | // 假定前后两次值的类型相同 152 | if (isObject(nextValue)) { 153 | value = {} 154 | for (let key in nextValue) { 155 | value[key] = getNestedValue(preValue[key], nextValue[key], currentTime, duringTime, easingFn) 156 | } 157 | // 数组类型 158 | } else if (Array.isArray(nextValue)) { 159 | value = [] 160 | for (let i = 0; i < nextValue.length; i += 1) { 161 | value[i] = getNestedValue(preValue[i], nextValue[i], currentTime, duringTime, easingFn) 162 | } 163 | } 164 | //... 165 | } 166 | ``` 167 | 其次是之前为元素创建动画时也没有传入这些属性。 168 | ```typescript 169 | class Animation { 170 | animateTo () { 171 | // ... 172 | let animateProps = [ 173 | 'shape', 174 | 'style', 175 | 'position', 176 | 'scale', 177 | 'origin', 178 | 'rotation' 179 | ] 180 | let animteTarget = {} 181 | animateProps.forEach(prop => { 182 | animteTarget[prop] = this[prop] 183 | }) 184 | this.animation = new Animation(animteTarget) 185 | // ... 186 | } 187 | } 188 | ``` 189 | 走你`animateTo({position: [20, 90]}, 400)`。可以看见已完全ok! 190 | ## 小结 191 | 本版本主要为元素应用了常见的`transform`变换,并完善了动画相关的细节。 192 | ## V5预览 193 | [`Group(组)`和`Layer(分层)`](./Version5.md)。 -------------------------------------------------------------------------------- /Version4_1.md: -------------------------------------------------------------------------------- 1 | # 从零打造Echarts —— XRender 0.4 2 | 本文开始v4版本。 3 | ## 回顾v3 4 | v3版本我们在v2的基础上添加了动画功能,可以实现对样式形状等的更新动画,但是最后发现少了很关键的`transform`动画,本版本就将为我们的`xrender`添加`transform`系统并同样实现它动画。 5 | ## 开始 6 | ### 什么是transform 7 | 就表现而言,是`translate(平移)`、`scale(缩放)`、`rotate(旋转)`、`skew(拉伸)`等效果,而其本质,则是相对坐标系的变换。平移和缩放很好理解,以矩形举例: 8 | 9 | **前方灵魂画手上线,请注意躲避!** 10 | 11 | ![](./images/v4/v4_1.png) 12 | 对该矩形放大(scale)1.5倍,则将其所以点的坐标都乘以1.5,则可得到变换后的矩形(以原点为变换中心点(下面会讲到),下同)。平移同理,加减即可。 13 | 14 | 拉伸则取对应轴角度的`tan`值即可。 15 | 16 | 而旋转则稍微复杂一点,容易想象初始矩形顺时针旋转90度(π / 2)的样子。下图绿色部分。 17 | 18 | ![](./images/v4/v4_2.png) 19 | 点(0, 1)是如何变换到(1, 0)的?很容易看出来 20 | - `x` = `x * cos(π / 2) - y * sin(π / 2)` 21 | - `y` = `y * sin(π / 2) + x * sin(π / 2)` 22 | 23 | ![](./images/v4/v4_3.gif) 24 | 25 | 啊住手!! 26 | 27 | 旋转90度是为了好画,并不有利于证明,以最容易的角度考虑,将`A(x1, y1)`点逆时针旋转`β`度到`A'(x2, y2)`,都在第一象限内,其它情况也差不多。 28 | ![](./images/v4/v4_4.png)。 29 | 30 | $$ 显然OA = OA' $$ 31 | $$ 而OA = \frac{y_1}{sin(a)} = \frac{x_1}{cos(a)} $$ 32 | $$ 且OA' = \frac{y_2}{sin(a + b)} = \frac{x_2}{cos(a + b)}$$ 33 | $$ 令它们都等于r$$ 34 | $$则 y_2 = r * (sin(a) * cos(b) + cos(a) * sin(b)) = y_1 * cos(b) + x_1 * sin(b)$$ 35 | $$则 x_2 = r * (cos(a) * cos(b) - sina(a) * sin(b)) = x_1 * cos(b) - y_1 * sin(b)$$ 36 | 37 | 上述所有变换用矩阵来表示就是 38 | $$ 39 | \begin{matrix} 40 | scaleX|cos(rotateZ) & tan(skewX)|-sin(rotateZ) & translateX \\ 41 | tan(skewY)|sin(rotateZ) & scaleY|cos(rotateZ) & translateY\\ 42 | 0 & 0 & 1 \\ 43 | \end{matrix} 44 | \,\,\,\, *\,\,\, 45 | \begin{matrix} 46 | x \\ 47 | y \\ 48 | 1 \\ 49 | \end{matrix} 50 | $$ 51 | 上面的六个变量,就是`transform`的基础。和`css`中有`matrix`一样,`canvas`提供了这样的`api`——`setTransform`。 52 | 53 | 但是想实现变换却没有这么简单。 54 | 1. 首先我们使用时不可能直接用矩阵去设置,需要将对应的属性转换成矩阵,如`translateX: 20`,转换为`[1, 0, 0, 1, 20, 0]`。 55 | > `canvas`不是提供了`translate`等方法吗,为什么不直接使用?在`Rect`中改写`beforeRender`方法来观察`transform`。 56 | 57 | 这是最开始的效果图。 58 | 59 | ![](./images/v4/v4_rect_scale1.png) 60 | 61 | 现在我们想让唯一的矩形放大2倍,更改`Rect.beforeRender`来实现。 62 | ```typescript 63 | beforeRender (ctx: CanvasRenderingContext2D) { 64 | super.beforeRender(ctx) 65 | // x y 同时放大2倍 66 | ctx.scale(2, 2) 67 | } 68 | ``` 69 | 效果如下: 70 | 71 | ![](./images/v4/v4_rect_scale2.png) 72 | 73 | 74 | 可以发现和我们想要的效果相差甚远(不管是基于左上角缩放还是中心点缩放): 75 | 76 | ![](./images/v4/v4_rect_scale_correct.png) 77 | 78 | 为什么会这样?因为`canvas`提供的变换是对**坐标轴**的变换,放大2倍之后绘制的所有数据都会放大2倍,包括这个矩形顶点的位置和宽高,而这显然(就我目前来看的话)是不符合需求的——虽然`zrender`中的设计也是如此,但是起码我现在觉得这样的效果不能让我满意。 79 | 80 | 如何解决这个问题?首先,先不管它,到后面再尝试解决;其次,这不是不用`canvas`提供的方法的主要原因。主要原因是,后面的功能需要计算出这个变换矩阵。 81 | 82 | 2. 其次存在多个变换属性时,如同时偏移和旋转,需要将二者经过第一步转换后再叠加计算,而这又牵扯到顺序问题了,是先偏移再旋转还是先旋转再偏移?是提供一个约定的方式,还是提供可选择的配置? -------------------------------------------------------------------------------- /Version5.md: -------------------------------------------------------------------------------- 1 | # 从零打造Echarts —— V5 分组、分层和逐帧绘制 2 | 本文开始v5版本。 3 | ## 回顾v4 4 | 在v4版本中我们为图形元素添加了`transform`功能,同样可以动画。 5 | ## Group 6 | 之前的代码中每个图形都是割裂开来的,无论什么操作只能一个图形一个图形地去设置,实际的使用中我们往往需要对同属于一部分的图形进行操作,比如左下角的图形都是蓝色描边,或者需要整体进行移动,这时候就需要用到分组了。 7 | 8 | 实际上很容易实现这一点——基于`XElement`创建`Group`类,为它添加一些管理子元素的方法——哦不对,为它创建一个`Stage`即可,然后在`render`中依次渲染子元素,而不是渲染自身,应该就能实现我们想要的效果了。 9 | 10 | 来试一下吧。 11 | ```typescript 12 | // 这一块直接复制的Rect,暂时没有用 13 | interface GroupShape extends XElementShape { 14 | /** 15 | * 左上角x 16 | */ 17 | x?: number 18 | /** 19 | * 左上角y 20 | */ 21 | y?: number 22 | width?: number 23 | height?: number 24 | } 25 | interface GroupOptions extends XElementOptions { 26 | shape?: GroupShape 27 | } 28 | 29 | class Group extends XElement { 30 | name ='group' 31 | shape: GroupShape = { 32 | x: 0, 33 | y: 0, 34 | width: 0, 35 | height: 0 36 | } 37 | stage: Stage 38 | constructor (opt: GroupOptions = {}) { 39 | super(opt) 40 | this.updateOptions() 41 | this.stage = new Stage() 42 | } 43 | render (ctx: CanvasRenderingContext2D) { 44 | let list = this.stage.getAll() 45 | list.forEach(xel => { 46 | xel.refresh(ctx) 47 | }) 48 | } 49 | /** 50 | * 需要为子元素也设置xr 51 | * 同样在之前的`XElement.attr`中需要修改为判断`_xr`存在才调用`render`,这里就不贴代码了 52 | */ 53 | setXr (xr: XRender) { 54 | super.setXr(xr) 55 | this.stage.getAll().forEach(xel => { 56 | xel.setXr(xr) 57 | }) 58 | } 59 | /** 60 | * 添加元素 61 | */ 62 | add (...xelements: XElement[]) { 63 | xelements.forEach(xel => { 64 | xel.setXr(this._xr) 65 | }) 66 | this.stage.add(...xelements) 67 | this._xr && this._xr.render() 68 | } 69 | /** 70 | * 删除元素 71 | */ 72 | delete (xel: XElement) { 73 | this.stage.delete(xel) 74 | } 75 | } 76 | ``` 77 | 将之前所有元素的`style`和`transform`相关属性都删掉。结果如图: 78 | ![](./images/v5/v5_2.png) 79 | 80 | - [ ] 呃,好像矩形的颜色有点不太对。 81 | 82 | 然后将左上角的两个圆分为一组,中间的圆和矩形为一组,分别设置样式。 83 | ```typescript 84 | let group1 = new xrender.Group({ 85 | style: { 86 | stroke: '#f00' 87 | } 88 | }) 89 | let group2 = new xrender.Group({ 90 | style: { 91 | stroke: '#0f0' 92 | } 93 | }) 94 | group1.add(circle, circle1) 95 | group2.add(circle2, rect) 96 | xr.add(group1, group2) 97 | ``` 98 | 然后看到 99 | 100 | ![](./images/v5/v5_1.png) 101 | 102 | Bingo!尝试了动画和其它属性都没有问题。顺便修复一个问题,在`XElement.updateOptions`中: 103 | ```typescript 104 | updateOptions () { 105 | if (opt.shape) { 106 | merge(this.shape, opt.shape) 107 | } else { 108 | opt.shape = {} 109 | } 110 | if (opt.style) { 111 | merge(this.style, opt.style) 112 | } else { 113 | opt.style = {} 114 | } 115 | } 116 | ``` 117 | 现在即使没有传入对应选项,更新属性时也不会报错了。 118 | ### 相对坐标 119 | 但有一个问题是,子元素在绘制时仍然是相对与整个画布来定位的(即世界坐标)—既然用了分组,想必是希望相对于父元素,即`Group`来定位。当然,有时也希望某一个元素仍然相对世界坐标来定位。 120 | 121 | 第一点很好解决,之前的代码就已经为`Group`指定了和`Rect`一样的`shape`属性,可以借此来达到目的。 122 | ```typescript 123 | class Group { 124 | // ... 125 | /** 126 | * 对于组,进行额外的变换使子元素能相对它定位 127 | */ 128 | setTransform (ctx: CanvasRenderingContext2D) { 129 | super.setTransform(ctx) 130 | ctx.translate(this.shape.x, this.shape.y) 131 | } 132 | } 133 | ``` 134 | 然后修改创建`group1`的代码 135 | ```typescript 136 | let group1 = new xrender.Group({ 137 | shape: { 138 | x: 40, 139 | y: 40, 140 | width: 0, 141 | height: 0 142 | }, 143 | style: { 144 | stroke: '#f00' 145 | } 146 | }) 147 | ``` 148 | 可以看到结果如期望的一样,`group1`内的图形都偏移了`(40, 40)`。 149 | 150 | ### 绝对坐标 151 | 那对于组内的某一个元素,想让临时相对于整个画布来定位,就像`fixed`一样;又或者对于嵌套的`Group`(显然是可以嵌套的),想让子元素相对于某一层级的`Group`定位,像`absolute`一样,应该如何去做呢? 152 | 153 | 可以这么考虑,给元素设定`relativeGroup`和`parent`属性(**如果这些属性是可以通过`attr`修改的,要在`updateOptions`方法中添加,以后不再说明**),在向`Group`添加元素时设定`Group`为这二者的值,元素`setTransorm`时对比两者是否相同,如果不同,调用父元素提供的重置变换的方法,将坐标系变换为父元素变换之前的坐标系,并一直向上取值,直到`parent.parent`为空为止。这并不难。 154 | ```typescript 155 | class XElement { 156 | relativeGroup: Group 157 | parent: Group 158 | /** 159 | * 设置相对元素的变换 160 | */ 161 | setRelativeTransform (ctx: CanvasRenderingContext2D) { 162 | let parent = this.parent 163 | // 如果不在一个组内,不需要做任何操作 164 | while (parent) { 165 | // 父元素和定位元素相同跳出循环 166 | if (parent === this.relativeGroup) { 167 | break 168 | } 169 | // 否则重置父元素 170 | parent.resumeTransform(ctx) 171 | parent = parent.parent 172 | } 173 | } 174 | /** 175 | * 为自身设置父元素,同时将父元素设为相对定位的元素 176 | * 然后将自身加入父元素的`stage`中 177 | */ 178 | setParent (parent: Group) { 179 | this.parent = parent 180 | this.relativeGroup = parent 181 | // 父元素不再负责这一步 182 | parent.stage.add(this) 183 | this.setXr(parent._xr) 184 | } 185 | } 186 | class Group { 187 | /** 188 | * 添加元素 189 | */ 190 | add (...xelements: XElement[]) { 191 | xelements.forEach(xel => { 192 | xel.setParent(this) 193 | }) 194 | this._xr && this._xr.render() 195 | } 196 | /** 197 | * 重置变换,为`setTransform`的逆过程,通常由子孙元素调用 198 | * 暂时定位只有`Group`有这个方法 199 | */ 200 | resumeTransform (ctx: CanvasRenderingContext2D) { 201 | ctx.translate(-this.shape.x, -this.shape.y) 202 | ctx.translate(-this.position[0], -this.position[1]) 203 | ctx.translate(...this.origin) 204 | ctx.rotate(1 / this.rotation) 205 | ctx.scale(1 / this.scale[0], 1 / this.scale[1]) 206 | ctx.translate(-this.origin[0], -this.origin[1]) 207 | } 208 | } 209 | ``` 210 | 如果一切顺利,将`relativeGroup`设为`null`即可使用世界坐标系了。 211 | ```typescript 212 | circle1.attr({ 213 | relativeGroup: null 214 | }) 215 | ``` 216 | ![](./images/v5/v5_3.png) 217 | 218 | 可以看到第二个圆已经挣脱父亲的怀抱了。 219 | > 当然,这样来设置这个属性一点都不`cool`,也很不方便,不过以后再说。 220 | ## Layer 221 | 一个图形应用一般都会由好几部分构成,而会经常变化,或者变化比较复杂的又可能只是其中一部分,图形较少的时候没有任何问题,如果图形比较多,就需要做一些优化来避免不必要的重绘以免引起卡顿。分层,就是常用的手段之一。 222 | 223 | 原理也很简单,就是对不同分层的元素用不同的`canvas`去展现,然后将对应的`canvas`叠加在一起即可。 224 | 225 | 以之前的效果为基础,我们将创建一个背景层——随便找个什么图片绘制上去,然后让背景层不断移动,看起来就像这几个图形在往前走一样。 226 | ### 一些问题 227 | 1. 将不同层的元素绘制在不同的`canvas`上这很容易实现,但是根据之前的代码,任何元素的更新都会引起整个`Painter`的重绘——这样的话分层就没什么意义了。也就是说,我们需要对层进行标记,重绘时如果这个层不需要更新,那么这个层上的所有元素都不会更新。如何做到这一点呢?一个办法是将层和元素关联起来,然后在`XElement.attr`中将自身所属的层标记为需要更新。但是这将使二者联系得非常紧密,如果重新设置一个元素的层级,也需要重新关联。另一个办法则是标记元素本身需要更新,遍历元素,如果元素需要更新,则找到对应的层标记为需要更新。考虑到耦合度、`zrender`的设计、以及后续功能(`Layer`完毕后会开始)的需要,选择第二个办法。 228 | ### 开始 229 | 实现步骤: 230 | 1. 如同为元素设计`zLevel`一样,为元素添加一个`zIndex`属性,它用来标记元素在哪一层。 231 | 2. 在`Stage.getAll`中根据`zIndex`和`zLevel`来排序——显然,`zIndex`的优先级应该更高。 232 | 3. 遍历元素,如果元素需要更新,将对应的层标记为需要更新,同时将层和属于这一层的元素关联起来。 233 | 4. 遍历层,如果层需要更新,绘制该层上的所有元素,否则什么也不做。 234 | 为此,重新设计`XElement`。 235 | ```typescript 236 | class XElement implements Transform { 237 | // ... 238 | zIndex = 1 239 | /** 240 | * 元素是否为脏,如果是,重绘时会更新元素所在层,脏检查,这是常用的名词,虽然我不太懂 241 | */ 242 | _dirty = true 243 | /** 244 | * 标记元素为脏 245 | * 在使用完毕后会标记为false 246 | */ 247 | dirty () { 248 | // 并不需要对父元素也进行标记 249 | this._dirty = true 250 | this._xr && this._xr.render() 251 | } 252 | attr (key: String | Object, value?: any) { 253 | // ... 254 | this.updateOptions() 255 | this.dirty() 256 | } 257 | } 258 | ``` 259 | 但一个问题是,如果一个组内的元素分不同的层怎么办?按之前的设计,一个组内的元素必然一起被重绘。但是我想还是可以解决这个问题——一是把父元素的样式绑定等内容移到子元素内进行,二是在`Stage.getAll`中将所有元素展开,也就是不再调用`Group.render`。下面来解决这个问题。 260 | ```typescript 261 | class XElement { 262 | /** 263 | * 在渲染之前对父元素进行处理 264 | * 包括应用样式等 265 | */ 266 | handleParentBeforeRender (ctx: CanvasRenderingContext2D) { 267 | if (this.parent) { 268 | this.parent.beforeRender(ctx) 269 | } 270 | } 271 | /** 272 | * 渲染之后对父元素进行处理 273 | * 主要是调用`restore` 274 | */ 275 | handleParentAfterRender (ctx: CanvasRenderingContext2D) { 276 | if (this.parent) { 277 | this.parent.afterRender(ctx) 278 | } 279 | }\/** 280 | * 绘制之前进行样式的处理 281 | */ 282 | beforeRender (ctx: CanvasRenderingContext2D) { 283 | this.handleParentBeforeRender(ctx) 284 | // ... 285 | } 286 | /** 287 | * 绘制之后进行还原 288 | */ 289 | afterRender (ctx: CanvasRenderingContext2D) { 290 | //... 291 | this.handleParentAfterRender(ctx) 292 | } 293 | } 294 | // 然后覆盖`Group`的`afterRender` 295 | class Group { 296 | afterRender (ctx: CanvasRenderingContext2D) { 297 | ctx.restore() 298 | } 299 | } 300 | ``` 301 | 302 | 重写`Stage.updateXElements` 303 | ```typescript 304 | class Stage { 305 | /** 306 | * 获取所有元素 307 | */ 308 | getAll () { 309 | let xelements = this.updateXElements() 310 | 311 | return xelements 312 | } 313 | updateXElements () { 314 | // zIndex高的在前 315 | // zLevel高的在后,其它按加入次序排列 316 | return this.expandXElements(callback).sort((a, b) => { 317 | let zIndex = b.zIndex - a.zIndex 318 | return zIndex === 0 ? a.zLevel - b.zLevel : zIndex 319 | }) 320 | } 321 | /** 322 | * 展开所有元素 323 | */ 324 | expandXElements () { 325 | let list: XElement[] = [] 326 | this.xelements.forEach(xel => { 327 | if (xel.stage) { 328 | list.push(...xel.stage.getAll()) 329 | } else if (!xel.ignored) { 330 | list.push(xel) 331 | } 332 | }) 333 | 334 | return list 335 | } 336 | } 337 | ``` 338 | 保存之后看下效果,之前的代码还是能正常运行,ok。 339 | 340 | 接下来创建`Layer`类,它负责创建`canvas`,所以创建`canvas`这些步骤从`Painter`内转移过来。另外一点是,因为存在一个或多个层的情况,为了方便处理,将之前可以传入容器或者`canvas`本身的设定改为只能传入容器。 341 | ```typescript 342 | import { isString } from './util' 343 | 344 | export interface LayerOptions { 345 | width?: number 346 | height?: number 347 | backgroundColor?: string 348 | } 349 | /** 350 | * 创建canvas 351 | */ 352 | function createCanvas (container: HTMLElement, opt: LayerOptions, zIndex: number) { 353 | let canvas = document.createElement('canvas'); 354 | container.appendChild(canvas) 355 | if(!opt.height) { 356 | opt.height = container.clientHeight 357 | } 358 | if (!opt.width) { 359 | opt.width = container.clientWidth 360 | } 361 | canvas.width = opt.width 362 | canvas.height = opt.height 363 | // 默认所有canvas都是透明的,这样才能叠加,只有最开始创建的层有背景色 364 | canvas.style.cssText = ` 365 | position: absolute; 366 | width: ${opt.width}px; 367 | height: ${opt.height}px; 368 | left: 0; 369 | top: 0; 370 | z-index: ${zIndex}; 371 | background-color: ${zIndex === 1 ? (opt.backgroundColor || 'transparent') : 'transparent'}; 372 | ` 373 | 374 | return canvas 375 | } 376 | 377 | class Layer { 378 | canvas: HTMLCanvasElement 379 | ctx: CanvasRenderingContext2D 380 | opt: LayerOptions 381 | /** 382 | * 同`XElement._dirty` 383 | */ 384 | _dirty = false 385 | /** 386 | * 层所属元素在所有元素列表中的开始和结束索引 387 | * 如果遍历所有元素,结束索引仍为-1,则应该销毁这一层 388 | */ 389 | startIndex = -1 390 | endIndex = -1 391 | constructor (container: HTMLElement, opt: LayerOptions = {}, zIndex = 1) { 392 | this.opt = opt 393 | let canvas = createCanvas(container, opt, zIndex) 394 | this.canvas = canvas 395 | this.ctx = canvas.getContext('2d') 396 | } 397 | /** 398 | * 绘制之前要清空画布 399 | */ 400 | clear () { 401 | this.ctx.clearRect(0, 0, this.opt.width, this.opt.height) 402 | } 403 | /** 404 | * 当一个层不再有元素和它关联,应该销毁自身和cavnas,以节省空间 405 | * 对于`XElement`,后续也会提供`dispose`方法 406 | */ 407 | dispose () { 408 | this.canvas.remove() 409 | this.canvas = null 410 | this.ctx = null 411 | this.opt = null 412 | 413 | } 414 | } 415 | 416 | export default Layer 417 | 418 | 419 | 420 | ``` 421 | **可以看到`Layer`类和之前的类最大的不同是它多了一个`dispose`方法——任何实例不再被需要时应该提供一个销毁自身的方法,后续我们会为所有类都添加这个方法。** 422 | 423 | 现在让我们回到`Painter`的构造函数 424 | ```typescript 425 | /** 426 | * 有多个层级时创建一个容器,让canvas相对于它定位 427 | */ 428 | function createRoot (width: number, height: number) { 429 | let root = document.createElement('div') 430 | root.style.cssText = ` 431 | position: relative; 432 | overflow: hidden; 433 | width: ${width}px; 434 | height: ${height}px; 435 | padding: 0; 436 | margin: 0; 437 | border-width: 0; 438 | ` 439 | 440 | return root 441 | } 442 | class Painter { 443 | // ... 444 | root: HTMLElement 445 | layerContainer: HTMLElement 446 | /** 447 | * 带层索引的对象 448 | */ 449 | layerListMap: { [prop: string]: Layer } = {} 450 | constructor (dom: string | HTMLElement, stage: Stage, opt: PainterOptions = {}) { 451 | this.opt = opt 452 | this.stage = stage 453 | let width = 0 454 | let height = 0 455 | if (isString(dom)) { 456 | dom = document.querySelector(dom as string) as HTMLElement 457 | } 458 | width = (dom).clientWidth 459 | height = (dom).clientHeight 460 | if (!opt.width) { 461 | opt.width = width 462 | } 463 | if (!opt.height) { 464 | opt.height = height 465 | } 466 | this.root = dom as HTMLElement 467 | let container = createRoot(opt.width, opt.height) 468 | this.layerContainer = container 469 | this.root.appendChild(container) 470 | /** 471 | * 默认的层 472 | */ 473 | let mainLayer = new Layer(container, opt) 474 | this.layerListMap[1] = mainLayer 475 | } 476 | } 477 | 478 | ``` 479 | 接着是`render`函数,在进行渲染之前对所有元素进行遍历,将元素和对应的层关联起来,当然新添加的`zIndex`可能还没有对应的层,创建一个即可。如何关联?容易想到的形式是将层关联的元素以数组或者键值对存储。但是一来会占据比较多的空间且频繁创建和销毁,二来则增加了耦合度。 480 | > 以上原因都是我瞎掰的。 481 | 482 | `zrender`提供了一个在我看来是启发了我的思路——在`Stage.getAll`中,我们已经根据元素的`zIndex`排序了,也就是说`Painter`内获取的元素列表是这样的: 483 | ```typescript 484 | // 数字代表元素和它的`zIndex` 485 | [1, 1, 1, 2, 2, 2, 3, 4, 4] 486 | ``` 487 | 为`Layer`添加开始索引`startIndex`和结束索引`endIndex`(`Layer`类的属性添加就不贴代码了),即可从元素列表中获取属于该层的元素,且它还有另外的功用。为此编写`updateLayerList`方法。 488 | ```typescript 489 | class Painter { 490 | // ... 491 | /** 492 | * 更新层 493 | */ 494 | updateLayerList (xelList: XElement[]) { 495 | let preLayer = null 496 | let layerList = this.layerListMap 497 | // 开始之前重置 498 | this.eachLayer((layer) => { 499 | layer.startIndex = -1 500 | layer.endIndex = -1 501 | }) 502 | for (let i = 0; i < xelList.length; i += 1) { 503 | let xel = xelList[i] 504 | let layer = layerList[xel.zIndex] || this.createLayer(xel.zIndex) 505 | // 到下一个层级了 506 | if (preLayer !== layer) { 507 | layer.startIndex = i 508 | } 509 | // 在这里进行标记 510 | if (xel._dirty) { 511 | layer._dirty = true 512 | } 513 | layer.endIndex = i 514 | preLayer = layer 515 | } 516 | // 结束之后还有没有元素关联的层,销毁 517 | // 第一层除外 518 | this.eachLayer((layer, zIndex) => { 519 | if (layer.startIndex === -1 && (parseInt(zIndex, 10) !== 1)) { 520 | layer.dispose() 521 | delete layerList[zIndex] 522 | } 523 | }) 524 | } 525 | /** 526 | * 创建新的层并加入列表中 527 | */ 528 | createLayer (zIndex: number) { 529 | let layer = new Layer(this.layerContainer, this.opt, zIndex) 530 | this.layerListMap[zIndex] = layer 531 | 532 | return layer 533 | } 534 | /** 535 | * 提供一个遍历层的方法 536 | */ 537 | eachLayer (fn: (layer: Layer, zIndex: string) => void) { 538 | // 从高到低 539 | let keys = Object.keys(this.layerListMap).sort((a, b) => b - a) 540 | for (let i in keys) { 541 | let key = keys[i] 542 | // 返回为true则跳出此次遍历 543 | if (fn(this.layerListMap[key], key) as boolean) { 544 | break 545 | } 546 | } 547 | } 548 | } 549 | ``` 550 | 然后在`render`中调用,并重写这一部分。 551 | ```typescript 552 | class Painter { 553 | render = debounce(() => { 554 | this.beforeRender() 555 | let xelements = this.stage.getAll() 556 | this.updateLayerList(xelements) 557 | this.eachLayer(layer => { 558 | if (!layer._dirty && parseInt(zIndex, 10) !== 1) { 559 | return 560 | } 561 | layer.clear() 562 | for (let i = layer.startIndex; i <= layer.endIndex; i += 1) { 563 | xelements[i].refresh(layer.ctx) 564 | xelements[i]._dirty = false 565 | } 566 | layer._dirty = false 567 | }) 568 | }, 10) 569 | } 570 | ``` 571 | 还是在之前的试验代码中改动,将其`circle1`的`zIndex`设为2,可以看到创建了我们想要的分层。即使它的`zLevel`更低,它也仍然在最上层。 572 | ![](./images/v5/v5_4.png)。 573 | 574 | 为了实现在本小节最开始描述的效果,我们首先需要创建`Image`类来展示图片。 575 | ```typescript 576 | import XElement, { XElementShape, XElementOptions } from './XElement' 577 | import { isString } from '../util' 578 | 579 | interface ImageShape extends XElementShape { 580 | /** 581 | * 左上角x 582 | */ 583 | x?: number 584 | /** 585 | * 左上角y 586 | */ 587 | y?: number 588 | /** 589 | * 图片宽度 590 | */ 591 | width?: number 592 | /** 593 | * 图片高度 594 | */ 595 | height?: number 596 | /** 597 | * 图片地址 598 | */ 599 | image?: string | CanvasImageSource 600 | } 601 | interface ImageOptions extends XElementOptions { 602 | shape?: ImageShape 603 | } 604 | 605 | class Image extends XElement { 606 | name ='rect' 607 | shape: ImageShape = { 608 | x: 0, 609 | y: 0, 610 | width: 0, 611 | height: 0 612 | } 613 | imgElement: CanvasImageSource 614 | constructor (opt: ImageOptions = {}) { 615 | super(opt) 616 | // 在创建实例时就尝试创建图片 617 | let image = opt.shape && opt.shape.image 618 | if (isString(image)) { 619 | image = document.createElement('img') 620 | image.src = opt.shape.image as string 621 | // 需要注意的是,如果传入的是一个字符串,要等图片载入之后再刷新一次 622 | image.onload = () => { 623 | this.dirty() 624 | } 625 | } 626 | this.imgElement = image as CanvasImageSource 627 | this.updateOptions() 628 | } 629 | render (ctx: CanvasRenderingContext2D) { 630 | let imgElement = this.imgElement 631 | if (imgElement instanceof HTMLImageElement) { 632 | if (!imgElement.complete) { 633 | return 634 | } 635 | } 636 | let shape = this.shape 637 | if (shape.sx !== undefined) { 638 | ctx.drawImage(imgElement, shape.sx, shape.sy, shape.sWidth, shape.sHeight, shape.x, shape.y, shape.width, shape.height) 639 | } else { 640 | ctx.drawImage(imgElement, shape.x, shape.y, shape.width, shape.height) 641 | } 642 | } 643 | } 644 | 645 | export default Image 646 | 647 | ``` 648 | 创建一张图片,并将其它元素的`zIndex`设为2。 649 | ```typescript 650 | let image = new xrender.Image({ 651 | shape: { 652 | x: 0, 653 | y: 0, 654 | width: 1920, 655 | height: 300, 656 | sx: 0, 657 | sy: 120, 658 | sHeight: 1080 - 300, 659 | sWidth: 1920, 660 | image: 'http://img.netbian.com/file/20130224/fc9a4762b276e6bcfc508945e686e2b8.jpg' 661 | } 662 | }) 663 | ``` 664 | ![](images/v5/v5_6.png) 665 | 666 | 然后对`image`添加动画,并在`Circle`的`render`中添加打印语句,观察没有被更新的层是否调用了`render`。 667 | ```typescript 668 | image.animateTo({ 669 | position: [-1000, 0] 670 | }, 2000) 671 | ``` 672 | ![](images/v5/v5_7.gif) 673 | 可以看到动画成功了,且这个过程没有再渲染背景层之外的元素。 674 | 675 | 目标达成! 676 | ## 渲染优化 677 | > 当然,本来目前还没到谈优化的时候,不过写到分层这里时想到了这个,也就顺便写下来了。 678 | 679 | ### 优化之前 680 | **在这之前,把之前代码中的`forEach`改为`for`循环;绘制时如果没有设置`transform`,也不再进行多余的`transform`变换。** 681 | 682 | --- 683 | 尝试如下代码 684 | ```typescript 685 | for (let i = 0; i < 20000; i += 1) { 686 | group1.add(new xrender.Circle({ 687 | shape: { 688 | cx: 150, 689 | cy: 200, 690 | r: i / 100 * 1 691 | }, 692 | style: { 693 | stroke: i % 2 === 0 ? '#f00' : '#00f' 694 | } 695 | })) 696 | } 697 | xr.add(group1, group2) 698 | ``` 699 | 可以看见有明显的卡顿。将数量提高到20万卡顿更加明显。有时我们的数据量虽然很大,但是并不需要它绘制完毕后才展现出来,可以使用分批绘制。原理很简单,就是绘制的时候限定时间,超过此时间则把剩下的元素放到下一帧去绘制,即逐帧绘制。当然,这只能基于层。 700 | 701 | 如何判定是否需要逐帧绘制呢?首先肯定是为`Layer`添加`renderByFrame`属性——同样在重置`layer`状态时设为`false`。因为需要把剩下的留到下一帧,所以要将绘制这一部分抽离为一个函数`paintList`,同时添加`drawIndex`属性,记录上一次绘制到哪儿了,如下: 702 | ```typescript 703 | class Painter { 704 | // ... 705 | /** 706 | * 正在绘制的id,调用`paintList`时,如果传入的id和最新的id不一样,则直接返回 707 | * 把元素留到下一帧绘制时,中途render被调用,则取消原来的绘制,重新绘制 708 | */ 709 | drawId: number 710 | this.beforeRender() 711 | let xelements = this.stage.getAll() 712 | this.updateLayerList(xelements) 713 | this.drawId = Math.random() 714 | this.painList(xelements, this.drawId) 715 | } 716 | painList (xelements: XElement[], drawId: number) { 717 | if (drawId !== this.drawId) { 718 | return 719 | } 720 | this.eachLayer(layer => { 721 | if (!layer._dirty && parseInt(zIndex, 10) !== 1) { 722 | return 723 | } 724 | /** 725 | * 是否逐帧绘制 726 | */ 727 | let userTimer = false 728 | let userTimer = layer.renderByFrame 729 | let startTime = Date.now() 730 | let startIndex = layer.drawIndex > -1 ? layer.drawIndex : layer.startIndex 731 | if (layer.drawIndex === -1) { 732 | layer.clear() 733 | } 734 | if (startIndex === -1) { 735 | return 736 | } 737 | for (let i = startIndex; i <= layer.endIndex; i += 1) { 738 | xelements[i].refresh(layer.ctx) 739 | xelements[i]._dirty = false 740 | // 多余的部分留到下一帧绘制 741 | if (Date.now() - startTime > 16 && userTimer) { 742 | layer.drawIndex = i 743 | requestAnimationFrame(() => { 744 | this.painList(xelements, drawId) 745 | }) 746 | return true 747 | } 748 | } 749 | layer._dirty = false 750 | }) 751 | } 752 | } 753 | ``` 754 | 关键什么时候来设置它呢?可以暴露一个设置层属性的方法——但是这样一来重置这个状态就变得麻烦了。可以和`_drity`一样,通过元素的`renderByFrame`属性来设置层的`renderByFrame`。即这个元素需要逐帧绘制,则该层所有元素都需要逐帧绘制——很显然的。 755 | ```typescript 756 | class XElement { 757 | /** 758 | * 是否需要逐帧绘制 759 | */ 760 | renderByFrame = false 761 | } 762 | class Painter { 763 | // ... 764 | /** 765 | * 更新层 766 | */ 767 | updateLayerList (xelList: XElement[]) { 768 | let preLayer = null 769 | let layerList = this.layerListMap 770 | // 开始之前重置 771 | this.eachLayer((layer) => { 772 | // ... 773 | layer.renderByFrame = false 774 | }) 775 | for (let i = 0; i < xelList.length; i += 1) { 776 | let xel = xelList[i] 777 | let layer = layerList[xel.zIndex] || this.createLayer(xel.zIndex) 778 | // ... 779 | if (xel.renderByFrame) { 780 | layer.renderByFrame = true 781 | } 782 | // ... 783 | } 784 | } 785 | } 786 | ``` 787 | 修改添加大量元素的代码如下: 788 | ```typescript 789 | for (let i = 0; i < 200000; i += 1) { 790 | group1.add(new xrender.Circle({ 791 | shape: { 792 | cx: 150, 793 | cy: 200, 794 | r: i / 100 * 1 795 | }, 796 | style: { 797 | stroke: i % 2 === 0 ? '#f00' : '#00f' 798 | }, 799 | renderByFrame: true 800 | })) 801 | } 802 | ``` 803 | ![](images/v5/v5_8.gif) 804 | ## 小结 805 | 本版本为元素添加了分组和分层的功能,做了一点小小优化,同时实现了数据量太大时能够逐帧绘制的功能。至此,我们的`xrender`的框架已搭建的差不多了,只剩下最后一个功能即可宣告收工。 806 | ## V6预览 807 | [事件处理](./Version6.md)。 -------------------------------------------------------------------------------- /Version6.1.md: -------------------------------------------------------------------------------- 1 | # 从零打造Echarts —— V6.5 描边、填充包含以及拖曳 2 | 3 | ## 精确检测 4 | 有了包围盒的初步检测,接下来就可以着手精确检测部分的代码了。 5 | 6 | 如何精确检测?首先我们要明确的是,精确检测分为两个部分,即填充检测和描边检测(需要考虑线宽),即有描边才检测描边,有填充才检测填充。描边检测很容易理解,对于每一段路径,计算待检测点是否在当前路径上即可。填充检测则稍微复杂一些,简单来说需要对所有路径综合考量。和构建包围盒一样,我们可以利用已经保存的路径数据来进行检测。为此创建`contain.ts`。 7 | ```typescript 8 | import { PathData, PathType } from './Path' 9 | 10 | /** 11 | * 路径包含检测 12 | * @param pathData 绘制的路径数据 13 | * @param lineWidth 线宽 14 | * @param isStroke 是否描边检测 15 | * @param x 待检测点横坐标 16 | * @param y 待监测点纵坐标 17 | */ 18 | function pathContain (data: PathData[], lineWidth: number, isStroke: boolean, x: number, y: number) { 19 | // 当前元素路径起始点,用于closePath,第一个命令和moveTo会改变它 20 | let start = [0, 0] 21 | // 上一个命令的终点 22 | let prePathFinal = [0, 0] 23 | // 当前命令的起点,用来和prePathFinal一起计算包围盒 24 | let pathStartPoint = [0, 0] 25 | let params: any[] 26 | let pathData: PathData 27 | // 非零规则的值 28 | let nozero = 0 29 | function windingPreToStart() { 30 | if (!isStroke) { 31 | nozero += windingLine( 32 | prePathFinal[0], 33 | prePathFinal[1], 34 | pathStartPoint[0], 35 | pathStartPoint[1], 36 | x, 37 | y 38 | ) 39 | } 40 | } 41 | // 遍历绘制过程,分别求取包围盒 42 | for (let i = 0; i < data.length; i += 1) { 43 | pathData = data[i] 44 | params = pathData.params 45 | pathStartPoint[0] = params[0] 46 | pathStartPoint[1] = params[1] 47 | // 对于第一个命令不是moveto的情况更新绘制起点和最开始的起点 48 | // 对于arc会在后续处理 49 | if (i === 0) { 50 | start[0] = pathStartPoint[0] 51 | start[1] = pathStartPoint[1] 52 | prePathFinal[0] = pathStartPoint[0] // x 53 | prePathFinal[1] = pathStartPoint[1] 54 | } 55 | // 根据绘制方法的不同用不同的计算方式 56 | switch (pathData.type) { 57 | case PathType.arc: 58 | } else { 59 | } 60 | pathStartPoint[0] = Math.cos(params[3]) * params[2] + params[0] 61 | pathStartPoint[1] = Math.sin(params[3]) * params[2] + params[1] 62 | prePathFinal[0] = Math.cos(params[4]) * params[2] + params[0] 63 | prePathFinal[1] = Math.sin(params[4]) * params[2] + params[1] 64 | if (i === 0) { 65 | start[0] = pathStartPoint[0] 66 | start[1] = pathStartPoint[1] 67 | } 68 | break 69 | case PathType.arcTo: 70 | if (isStroke) { 71 | 72 | } else { 73 | 74 | } 75 | break 76 | case PathType.bezierCurveTo: 77 | if (isStroke) { 78 | } else { 79 | } 80 | prePathFinal[0] = params[4] 81 | prePathFinal[1] = params[5] 82 | break 83 | case PathType.lineTo: 84 | if (isStroke) { 85 | } else { 86 | } 87 | prePathFinal[0] = params[0] 88 | prePathFinal[1] = params[1] 89 | break 90 | case PathType.moveTo: 91 | start[0] = params[0] // x 92 | start[1] = params[1] // y 93 | prePathFinal[0] = params[0] // x 94 | prePathFinal[1] = params[1] // y 95 | break 96 | case PathType.quadraticCurveTo: 97 | if (isStroke) { 98 | } else { 99 | } 100 | prePathFinal[0] = params[2] 101 | prePathFinal[1] = params[3] 102 | break 103 | case PathType.rect: 104 | let x0 = params[0] 105 | let y0 = params[1] 106 | let x1 = x0 + params[2] 107 | let y1 = y0 + params[3] 108 | if (isStroke) { 109 | } else { 110 | } 111 | prePathFinal[0] = params[0] 112 | prePathFinal[1] = params[1] 113 | break 114 | case PathType.drawImage: 115 | // 同rect,但是没有描边 116 | break 117 | case PathType.closePath: 118 | if (isStroke) { 119 | } else { 120 | } 121 | prePathFinal[0] = start[0] 122 | prePathFinal[1] = start[1] 123 | default: 124 | break 125 | } 126 | } 127 | } 128 | /** 129 | * 描边检测 130 | * @param pathData 绘制的路径数据 131 | * @param lineWidth 线宽 132 | * @param x 待检测点横坐标 133 | * @param y 待监测点纵坐标 134 | */ 135 | export function containStroke (pathDatas: PathData[], lineWidth: number, x: number, y: number) { 136 | return pathContain(pathDatas, lineWidth, true, x, y) 137 | } 138 | /** 139 | * 填充检测 140 | * @param pathData 绘制的路径数据 141 | * @param x 待检测点横坐标 142 | * @param y 待监测点纵坐标 143 | */ 144 | export function contain (pathDatas: PathData[], x: number, y: number) { 145 | return pathContain(pathDatas, 0, false, x, y) 146 | } 147 | ``` 148 | 然后在`XElement.contain`中应用: 149 | ```typescript 150 | class XElement { 151 | /** 152 | * 是否包含某个点 153 | */ 154 | contain (x: number, y: number) { 155 | let local = this.getLocalCord(x, y) 156 | x = local[0] 157 | y = local[1] 158 | 159 | if (this.getBoundingRect().contain(x, y)) { 160 | if (this.hasStroke()) { 161 | if (containStroke(this.path.data, this.style.lineWidth, x, y)) { 162 | return true 163 | } 164 | } 165 | if (this.hasFill()) { 166 | return contain(this.path.data, x, y) 167 | } 168 | } 169 | } 170 | ``` 171 | ### 描边检测 172 | 目前为止,绘制的路径都是有一个方程的,只需要检测点是否为方程的解,就可得出结果。 173 | 174 | 让我们从最简单的线段描边检测开始。 175 | #### Line 176 | 对于线段而言,检测是否包含是很简单的,代入待检测点横纵标求出线段的纵坐标范围,再检测纵坐标是否在范围内即可。 177 | ![](images/v6/line-stroke.png) 178 | 179 | ![](math/v6/公式_4.png) 180 | ```typescript 181 | // contain.ts 182 | /** 183 | * 线段描边包含 184 | */ 185 | function lineContainStroke (x0: number, y0: number, x1: number, y1: number, lineWidth: number, x: number, y: number) { 186 | /** 187 | * 斜率 188 | */ 189 | let a = 0 190 | let b = x0 191 | let halfLineWidth = lineWidth / 2 192 | // 虽然使用时点在包围盒内,但是函数内却不能如此假设 193 | if ( 194 | (y > y0 + lineWidth && y > y1 + lineWidth) 195 | || (y < y0 - lineWidth && y < y1 - lineWidth) 196 | || (x > x0 + lineWidth && x > x1 + lineWidth) 197 | || (x < x0 - lineWidth && x < x1 - lineWidth) 198 | ) { 199 | return false 200 | } 201 | // 如果不是平行于y轴 202 | if (x0 !== x1) { 203 | a = (y0 - y1) / (x0 - x1) 204 | b = y0 - a * x0 205 | } else { 206 | // 垂直方向上的线只需要考虑是否超出宽度边界 207 | return Math.abs(x - x0) <= halfLineWidth 208 | } 209 | // y轴在此线段所在直线的横截跨度的平方 210 | let tSquare = (a * a + 1) * (lineWidth * lineWidth) 211 | // 理想的坐标点 212 | let temp = a * x - y + b 213 | 214 | return temp * temp <= tSquare / 4 215 | } 216 | // 接着应用它 217 | function containPath () { 218 | // ... 219 | switch (pathData.type) { 220 | case PathType.lineTo: 221 | if (isStroke) { 222 | if (lineContainStroke( 223 | start[0], 224 | start[1], 225 | params[0], 226 | params[1], 227 | lineWidth, 228 | x, 229 | y 230 | )) { 231 | return true 232 | } 233 | } 234 | } 235 | ``` 236 | 创建一个线段试试吧。可以发现能够准确检测了。欧耶! 237 | #### closePath 238 | 遇到`closePath`时则处理方式和`line`差不多,只是需要额外的点来记住起始点。 239 | ```typescript 240 | // ... 241 | case PathType.closePath: 242 | if (isStroke) { 243 | if (lineContainStroke(start[0], start[1], outset[0], outset[1], lineWidth, x, y)) { 244 | return true 245 | } 246 | } 247 | ``` 248 | #### rect 249 | 而对于矩形,事情就简单了很多,只需要对四条边分别进行检测即可。 250 | ```typescript 251 | // ... 252 | case PathType.rect: 253 | let x0 = params[0] 254 | let y0 = params[1] 255 | let x1 = x0 + params[2] 256 | let y1 = y0 + params[3] 257 | if (isStroke) { 258 | if ( 259 | lineContainStroke(x0, y0, x1, y0, lineWidth, x, y) || 260 | lineContainStroke(x0, y0, x0, y1, lineWidth, x, y) || 261 | lineContainStroke(x0, y1, x1, y1, lineWidth, x, y) || 262 | lineContainStroke(x1, y0, x1, y1, lineWidth, x, y) 263 | ) { 264 | return true 265 | } 266 | } 267 | ``` 268 | 创建一个矩形,可以发现检测成功了。 269 | #### arc 270 | 接下来是对圆弧的检测。由下图,容易想到这样的步骤: 271 | ![](./images/v6/circle-stroke.png) 272 | 273 | 1. 到圆心的距离是否在圆周上。如果在,进入第二步。 274 | 2. 如果圆弧的角度接近一个圆,则认为包含,否则进入第三步。 275 | 3. 计算待检测点所在半径角度是否在圆弧的角度范围内。 276 | ```typescript 277 | // contain.ts 278 | const PI2 = Math.PI * 2 279 | /** 280 | * 将弧度转换为0到2PI内 281 | */ 282 | function normalizeRadian(angle: number) { 283 | angle %= PI2 284 | if (angle < 0) { 285 | angle += PI2 286 | } 287 | 288 | return angle 289 | } 290 | /** 291 | * 圆弧描边包含 292 | */ 293 | function arcContainStroke ( 294 | cx: number, cy: number, r: number, startAngle: number, endAngle: number, anticlockwise: boolean, 295 | lineWidth: number, x: number, y: number 296 | ) { 297 | x -= cx 298 | y -= cy 299 | // 到圆心的距离 300 | let d = Math.sqrt(x * x + y * y) 301 | let halfLineWidth = lineWidth / 2 302 | // 不在圆周上 303 | if ((d - r) > halfLineWidth || (d - r) < -halfLineWidth) { 304 | return false 305 | } 306 | // 近似一个圆时不再判断 307 | if (Math.abs(startAngle - endAngle) % PI2 < 1e-4) { 308 | return true 309 | } 310 | if (anticlockwise) { 311 | let temp = startAngle 312 | startAngle = normalizeRadian(endAngle) 313 | endAngle = normalizeRadian(temp) 314 | } else { 315 | startAngle = normalizeRadian(startAngle) 316 | endAngle = normalizeRadian(endAngle) 317 | } 318 | if (startAngle > endAngle) { 319 | endAngle += PI2 320 | } 321 | // 求出点所在半径的角度 322 | let angle = Math.atan2(y, x) 323 | if (angle < 0) { 324 | angle += PI2 325 | } 326 | // 需要注意角度超过一周的情况 327 | return (angle >= startAngle && angle <= endAngle) || 328 | (angle + PI2 >= startAngle && angle + PI2 <= endAngle) 329 | } 330 | function pathContain () { 331 | // ... 332 | case PathType.arc: 333 | if (isStroke) { 334 | if (arcContainStroke( 335 | params[0], 336 | params[1], 337 | params[2], 338 | params[3], 339 | params[4], 340 | params[5], 341 | lineWidth, 342 | x, 343 | y 344 | )) { 345 | return true 346 | } 347 | } 348 | } 349 | ``` 350 | 不过一个容易发现的问题是,考虑如下代码(此时将`XElement.afterRender`中描边和填充的顺序调换一下): 351 | ```typescript 352 | ctx.moveTo(10, 10) 353 | ctx.arc(shape.cx, shape.cy, shape.r * 2, shape.startAngle, shape.endAngle, !shape.clockwise) 354 | ctx.arc(shape.cx, shape.cy, shape.r, shape.startAngle, shape.endAngle, !shape.clockwise) 355 | ``` 356 | ![](images/v6/arc-stroke.png) 357 | 358 | 可以看到有两条不属于我们绘制的路径,但是仍然被绘制出来了,这是因为没有调用`moveTo`,但是画笔仍然移动了,且这个过程留下了路径。所以,应该添加如下的处理代码。 359 | ```typescript 360 | // contain.ts 361 | function pathContain () { 362 | for () { 363 | // ... 364 | // 对于非moveto的造成的移动,圆弧在后面处理 365 | if ( 366 | isStroke 367 | && (pathData.type !== PathType.arc) 368 | && (pathData.type !== PathType.moveTo) 369 | && (pathData.type !== PathType.closePath) 370 | ) { 371 | if (lineContainStroke(prePathFinal[0], prePathFinal[1], pathStartPoint[0], pathStartPoint[1], lineWidth, x, y)) { 372 | return true 373 | } 374 | } 375 | case PathType.arc: 376 | // ... 377 | pathStartPoint[0] = Math.cos(params[3]) * params[2] + params[0] 378 | pathStartPoint[1] = Math.sin(params[3]) * params[2] + params[1] 379 | if ( 380 | isStroke 381 | ) { 382 | if (lineContainStroke(prePathFinal[0], prePathFinal[1], pathStartPoint[0], pathStartPoint[1], lineWidth, x, y)) { 383 | return true 384 | } 385 | } 386 | } 387 | } 388 | ``` 389 | #### quadraticCurve 390 | 从二次贝塞尔曲线开始。 391 | ![](images/v6/curve_stroke.png) 392 | 393 | 很容易想到,就像对于线段的判断那样来判断贝塞尔曲线,只不过对于对于线宽的处理略有不同,但是只要求出曲线上当前点的切线斜率即可。关键是另一个问题,对于起始点(x0, y0), 终点(x2, y2)间的点(x, y),对于曲线方程 394 | 395 | ![](math/v6/公式_5.png) 396 | 397 | 需要先求出t才能进行下一步。而求解二次方程是非常简单的,此处不再多说。 398 | 399 | 而切线斜率呢,从之前的图(可以回去翻一下)可以看出很容易求出。 400 | 401 | 在`util/curve.ts`中写如下函数: 402 | ```typescript 403 | /** 404 | * 计算二次贝塞尔曲线在某一点的切线的斜率 405 | */ 406 | export function quadraticTangentSlope ( 407 | x0: number, y0: number, x1: number, y1: number, x2: number, y2: number, t: number 408 | ) { 409 | let q0x = x0 + (x1 - x0) * t 410 | let q0y = y0 + (y1 - y0) * t 411 | let q1x = x1 + (x2 - x1) * t 412 | let q1y = y1 + (y2 - y1) * t 413 | 414 | return (q0y - q1y) / (q0x - q1x) 415 | } 416 | /** 417 | * 计算二次方贝塞尔方程根 418 | */ 419 | export function quadraticRootAt(p0: number, p1: number, p2: number, val: number, roots: any[], lineWidth?: number) { 420 | // 如果在有线宽的情况下,不考虑线宽就会出现没有解的情况 421 | // 这里暂时是指求解x时 422 | if (lineWidth) { 423 | if (val < p0 && (p0 - val) <= lineWidth / 2) { 424 | val = p0 425 | } else if (val > p1 && (val - p1) <= lineWidth / 2) { 426 | val = p1 427 | } 428 | } 429 | let a = p0 - 2 * p1 + p2 430 | let b = 2 * (p1 - p0) 431 | let c = p0 - val 432 | 433 | let n = 0 434 | if (isAroundZero(a)) { 435 | if (isNotAroundZero(b)) { 436 | let t1 = -c / b 437 | if (t1 >= 0 && t1 <= 1) { 438 | roots[n++] = t1 439 | } 440 | } 441 | } 442 | else { 443 | let disc = b * b - 4 * a * c 444 | if (isAroundZero(disc)) { 445 | let t1 = -b / (2 * a) 446 | if (t1 >= 0 && t1 <= 1) { 447 | roots[n++] = t1; 448 | } 449 | } 450 | else if (disc > 0) { 451 | let discSqrt = Math.sqrt(disc); 452 | let t1 = (-b + discSqrt) / (2 * a) 453 | let t2 = (-b - discSqrt) / (2 * a) 454 | if (t1 >= 0 && t1 <= 1) { 455 | roots[n++] = t1 456 | } 457 | if (t2 >= 0 && t2 <= 1) { 458 | roots[n++] = t2 459 | } 460 | } 461 | } 462 | return n 463 | } 464 | ``` 465 | 如此计算它的包含就变得简单了: 466 | ```typescript 467 | /** 468 | * 二次贝塞尔曲线描边包含 469 | */ 470 | function quadraticContainStroke ( 471 | x0: number, y0: number, x1: number, y1: number, x2: number, y2: number, 472 | lineWidth: number, x: number, y: number 473 | ) { 474 | // 有一个大概的范围判断 475 | if ( 476 | (y > y0 + lineWidth && y > y1 + lineWidth && y > y2 + lineWidth) || 477 | (y < y0 - lineWidth && y < y1 - lineWidth && y < y2 - lineWidth) || 478 | (x > x0 + lineWidth && x > x1 + lineWidth && x > x2 + lineWidth) || 479 | (x < x0 - lineWidth && x < x1 - lineWidth && x < x2 - lineWidth) 480 | ) { 481 | return false 482 | } 483 | let roots = [] 484 | // 求出当前t值 485 | let n = quadraticRootAt(x0, x1, x2, x, roots, lineWidth) 486 | // 对于x来说,只有一个解 487 | if (n !== 1) { 488 | return 489 | } 490 | let t = roots[0] 491 | // 和原始点的差值 492 | let d = quadraticAt(y0, y1, y2, t) - y 493 | // 斜率 494 | let k = quadraticTangentSlope( 495 | x0, y0, x1, y1, x2, y2, t 496 | ) 497 | // 当前点所在垂直线截取的曲线的长度的平方 498 | let sSquare = (k * k + 1) * (lineWidth * lineWidth) 499 | 500 | return d * d <= sSquare / 4 501 | } 502 | function pathContain () { 503 | // ... 504 | case PathType.quadraticCurveTo: 505 | if (isStroke) { 506 | if (quadraticContainStroke( 507 | start[0], 508 | start[1], 509 | params[0], 510 | params[1], 511 | params[2], 512 | params[3], 513 | lineWidth, 514 | x, 515 | y 516 | )) { 517 | return true 518 | } 519 | } 520 | } 521 | ``` 522 | 经过测试呢,发现在某些边缘存在一点点的瑕疵,但基本能够满足需求。 523 | #### cubicCurve 524 | 三次贝塞尔曲线和二次差不多,就不解释了。当然了,解三次方程和二次方程的难度不是一个级别的。 525 | 526 | > 考虑这样一个问题,虽然三次方程的解比较麻烦,但是仍能求解。如果是更高阶的贝塞尔曲线,很难通过公式来求解时,又该如何去解决这个问题呢?就我能想到的思路是对曲线进行降阶拆分,不过我是一个数学渣渣,就不验证了。 527 | ```typescript 528 | // curve.ts 529 | /** 530 | * 计算三次贝塞尔曲线在某一点的切线的斜率 531 | */ 532 | export function cubicTangentSlope ( 533 | x0: number, y0: number, x1: number, y1: number, 534 | x2: number, y2: number, x3: number, y3: number, t: number 535 | ) { 536 | let q0x = x0 + (x1 - x0) * t 537 | let q0y = y0 + (y1 - y0) * t 538 | let q1x = x1 + (x2 - x1) * t 539 | let q1y = y1 + (y2 - y1) * t 540 | let q2x = x2 + (x3 - x2) * t 541 | let q2y = y2 + (y3 - y2) * t 542 | 543 | let r0x = q0x + (q1x - q0x) * t 544 | let r0y = q0y + (q1y - q0y) * t 545 | let r1x = q1x + (q2x - q1x) * t 546 | let r1y = q1y + (q2y - q1y) * t 547 | 548 | return (r0y - r1y) / (r0x - r1x) 549 | } 550 | /** 551 | * 用于开方 552 | */ 553 | function mathPow (x: number, y: number) { 554 | if (x < 0) { 555 | return -Math.pow(-x, y) 556 | } 557 | 558 | return Math.pow(x, y) 559 | } 560 | /** 561 | * 计算三次贝塞尔方程根,使用盛金公式 562 | */ 563 | export function cubicRootAt( 564 | p0: number, p1: number, p2: number, p3: number, val: number, 565 | roots: any[], lineWidth: number 566 | ) { 567 | // 如果在有线宽的情况下,不考虑线宽就会出现没有解的情况 568 | // 这里暂时是指求解x时 569 | if (lineWidth) { 570 | if (val < p0 && (p0 - val) <= lineWidth / 2) { 571 | val = p0 572 | } else if (val > p3 && (val - p3) <= lineWidth / 2) { 573 | val = p3 574 | } 575 | } 576 | // 系数 577 | let a = p3 + 3 * (p1 - p2) - p0 578 | let b = 3 * (p2 - p1 * 2 + p0) 579 | let c = 3 * (p1 - p0) 580 | let d = p0 - val 581 | // 重根判别式 582 | let A = b * b - 3 * a *c 583 | let B = b * c - 9 * a * d 584 | let C = c * c - 3 * b * d 585 | 586 | let n = 0 587 | let t1 588 | let t2 589 | let t3 590 | // A=B=0时,三根相同 591 | if (isAroundZero(A) && isAroundZero(B)) { 592 | if (isAroundZero(b)) { 593 | roots[0] = 0 594 | n += 1 595 | } else { 596 | // 盛金公式1 597 | t1 = -c / b 598 | if (t1 >= 0 && t1 <= 1) { 599 | roots[n++] = t1 600 | } 601 | } 602 | } else { 603 | // 总判别式 604 | let delta = B * B - 4 * A * C 605 | if (isAroundZero(delta)) { 606 | // 盛金公式3 607 | let K = B / A 608 | t1 = -b / a + K 609 | t2 = -K / 2 610 | if (t1 >= 0 && t1 <= 1) { 611 | roots[n++] = t1 612 | } 613 | if (t2 >= 0 && t2 <= 1) { 614 | roots[n++] = t2 615 | } 616 | } else if (delta > 0) { 617 | // 盛金公式2 618 | let deltaSqrt = Math.sqrt(delta) 619 | let Y1 = A * b + 1.5 * a * (-B + deltaSqrt) 620 | let Y2 = A * b + 1.5 * a * (-B - deltaSqrt) 621 | Y1 = mathPow(Y1, 1 / 3) 622 | Y2 = mathPow(Y2, 1 / 3) 623 | t1 = (-b - (Y1 + Y2)) / (3 * a) 624 | // 复数根不包括在内 625 | if (t1 >= 0 && t1 <= 1) { 626 | roots[n++] = t1 627 | } 628 | } else { 629 | // 盛金公式4 630 | let T = (2 * A * b - 3 * a * B) / (2 * A * Math.sqrt(A)) 631 | let theta = Math.acos(T) / 3 // theta / 3 632 | let ASqrt = Math.sqrt(A) 633 | let temp = Math.cos(theta) 634 | let THREE_SQRT = Math.sqrt(3) 635 | t1 = (-b - 2 * ASqrt * temp) / (3 * a) 636 | t2 = (-b + ASqrt * (temp + THREE_SQRT * Math.sin(theta))) / (3 * a) 637 | t3 = (-b + ASqrt * (temp - THREE_SQRT * Math.sin(theta))) / (3 * a) 638 | if (t1 >= 0 && t1 <= 1) { 639 | roots[n++] = t1; 640 | } 641 | if (t2 >= 0 && t2 <= 1) { 642 | roots[n++] = t2; 643 | } 644 | if (t3 >= 0 && t3 <= 1) { 645 | roots[n++] = t3; 646 | } 647 | } 648 | } 649 | 650 | return n 651 | } 652 | // contain.ts 653 | /** 654 | * 三次贝塞尔曲线描边包含 655 | */ 656 | function cubicContainStroke ( 657 | x0: number, y0: number, x1: number, y1: number, 658 | x2: number, y2: number, x3: number, y3: number, 659 | lineWidth: number, x: number, y: number 660 | ) { 661 | // 有一个大概的范围判断 662 | if ( 663 | (y > y0 + lineWidth && y > y1 + lineWidth && y > y2 + lineWidth && y > y3 + lineWidth) || 664 | (y < y0 - lineWidth && y < y1 - lineWidth && y < y2 - lineWidth && y < y3 - lineWidth) || 665 | (x > x0 + lineWidth && x > x1 + lineWidth && x > x2 + lineWidth && x > x3 + lineWidth) || 666 | (x < x0 - lineWidth && x < x1 - lineWidth && x < x2 - lineWidth && x < x3 - lineWidth) 667 | ) { 668 | return false 669 | } 670 | let roots = [] 671 | // 求出当前t值 672 | let n = cubicRootAt(x0, x1, x2, x3, x, roots, lineWidth) 673 | // 对于x来说,只有一个解 674 | if (n !== 1) { 675 | return 676 | } 677 | let t = roots[0] 678 | // 和原始点的差值 679 | let d = cubicAt(y0, y1, y2, y3, t) - y 680 | // 斜率 681 | let k = cubicTangentSlope( 682 | x0, y0, x1, y1, x2, y2, x3, y3, t 683 | ) 684 | // 当前点所在垂直线截取的曲线的长度的平方 685 | let sSquare = (k * k + 1) * (lineWidth * lineWidth) 686 | 687 | return d * d <= sSquare / 4 688 | } 689 | function pathContain () { 690 | // ... 691 | case PathType.bezierCurveTo: 692 | if (isStroke) { 693 | if (cubicContainStroke( 694 | start[0], 695 | start[1], 696 | params[0], 697 | params[1], 698 | params[2], 699 | params[3], 700 | params[4], 701 | params[5], 702 | lineWidth, 703 | x, 704 | y 705 | )) { 706 | return true 707 | } 708 | } 709 | } 710 | ``` 711 | 验证,bingo! 712 | ## 填充检测 713 | 如何进行填充检测?其实可以参考我们在使用`ctx.fill`时,它是如何着色的——内部点才回着色,外部点则不会。而恰好`ctx.fill`是可以选择填充规则的,通过这个规则我们可以摸索出究竟该如何判定。 714 | 715 | 查看`api`可以发现默认的规则为`nozero`,即非零规则,而它的表现符合我们的需求,所以可以选定此规则来进行下一步。 716 | 717 | ### 非零规则 718 | 什么是非零规则? 719 | > 以下引用部分图文来自[张鑫旭](https://www.zhangxinxu.com/wordpress/2018/10/nonzero-evenodd-fill-mode-rule/)。 720 | 721 | > 我们要判断某一个区域是路径内还是路径外,很简单,在这个区域内任意找一个点,然后以这个点为起点,发射一条无限长的射线,然后—— 722 | 723 | > 对于nonzero规则:起始值为0,射线会和路径相交,如果路径方向和射线方向形成的是顺时针方向则+1,如果是逆时针方向则-1,最后如果数值为0,则是路径的外部;如果不是0,则是路径的内部,因此被称为“非0规则”。 724 | 一图胜千言: 725 | 726 | ![](./images/v6/nozero_1.png) 727 | 728 | > 非零规则计数示意 729 | 730 | > 例如上图点A,我们随便发出一条射线,结果经过了路径5和路径2,我们顺着路径前进方向和射线前进方向,可以看到,合并后的运动方向都是逆时针,逆时针方向-1,因此,最后计算值是-2,不是0,因此,是内部,fill时候可以被填充。 731 | 732 | > 再看外部的例子,一图胜千言+1: 733 | 734 | ![](./images/v6/nozero_2.png) 735 | 736 | > 非零规则路径外示意 737 | 738 | > 点B再发出一条射线,经过两条路径片段,为路径2和路径3,我们顺着路径前进方向和射线前进方向,可以看到,合并后的运动方向一个是逆时针,-1,一个是顺时针,+1,因此,最后的计算值是0,是外部,因此,不被填充。 739 | 740 | 明白了以上内容之后,又该怎么应用它来检测呢?容易想到的是,从待检测点出发,作一条延某个方向水平或者垂直的线,检测这条线和每一段路径的交叉方向,顺时针`+1`,逆时针`-1`(同`zrender`),没有交点则`+0`, 在路径上则为无穷大。 741 | 742 | 如果水平线路过路径的端点,那么结果除以2。这是因为点如果在路径交点处,需要被重复计算。如下图: 743 | 744 | ![](images/v6/nozero_3.png) 745 | 746 | 如果不添加这条规则,那么图示红点的结果非零,但是却在外部。添加此规则之后,则又能正确计算了。与之对比的是非端点处的交点。 747 | 748 | ![](images/v6/nozero_4.png) 749 | 750 | ~~弧没有这个规则,原因可以画图自行领会。~~ 751 | 完毕之后的结果非零,则包含,否则不包含。代码结构如下: 752 | ```typescript 753 | // contain.ts 754 | /** 755 | * 对有向线段非零规则检测 756 | */ 757 | function windingLine () { 758 | return 0 759 | } 760 | /** 761 | * 对三次贝塞尔曲线非零规则检测 762 | */ 763 | function windingCubic () { 764 | return 0 765 | } 766 | /** 767 | * 对二次贝塞尔曲线非零规则检测 768 | */ 769 | function windingQuadratic () { 770 | return 0 771 | } 772 | /** 773 | * 对弧非零规则检测 774 | */ 775 | function windingArc () { 776 | return 0 777 | } 778 | function pathContain () { 779 | // 非零规则的值 780 | let nozero = 0 781 | // 遍历绘制过程,分别求取包围盒 782 | for (let i = 0; i < data.length; i += 1) { 783 | // ... 784 | switch (pathData.type) { 785 | case PathType.arc: 786 | if (isStroke) { 787 | } else { 788 | nozero += windingArc() 789 | } 790 | break 791 | case PathType.arcTo: 792 | if (isStroke) { 793 | 794 | } else { 795 | 796 | } 797 | break 798 | case PathType.bezierCurveTo: 799 | if (isStroke) { 800 | } else { 801 | nozero += windingCubic() 802 | }5] 803 | break 804 | case PathType.lineTo: 805 | if (isStroke) { 806 | } else { 807 | nozero += windingLine() 808 | } 809 | break 810 | case PathType.moveTo: 811 | break 812 | case PathType.quadraticCurveTo: 813 | if (isStroke) { 814 | } else { 815 | nozero += windingQuadratic() 816 | } 817 | break 818 | case PathType.rect: 819 | if (isStroke) { 820 | } else { 821 | // 对于矩形,检测左右两点线段即可 822 | nozero += windingLine() 823 | nozero += windingLine() 824 | } 825 | start[0] = params[0] 826 | start[1] = params[1] 827 | break 828 | case PathType.drawImage: 829 | // 同rect,但是没有描边 830 | nozero += windingLine() 831 | nozero += windingLine() 832 | break 833 | case PathType.closePath: 834 | if (isStroke) { 835 | } else { 836 | nozero += windingLine() 837 | } 838 | default: 839 | break 840 | } 841 | } 842 | 843 | return nozero !== 0 844 | } 845 | ``` 846 | 让我们开始吧。 847 | ### line 848 | 从最简单的线段开始,当然这里的线段是有向线段。 849 | > 以下所有推导均已省略了大致的判断。 850 | > 851 | > 选择做水平向右的线。 852 | 853 | ![](./images/v6/line-fill.png) 854 | 855 | ![](math/v6/公式_6.png) 856 | 857 | 可以写出如下代码。 858 | ```typescript 859 | /** 860 | * 对有向线段非零规则检测 861 | */ 862 | function windingLine (x0: number, y0: number, x1: number, y1: number, x: number, y: number) { 863 | if ((y > y0 && y > y1) || (y < y0 && y < y1)) { 864 | return 0 865 | } 866 | // 忽略水平线段 867 | if (y1 === y0 && (y !== y0)) { 868 | return 0 869 | } 870 | let dir = y1 < y0 ? 1 : -1 871 | // 对于会路过路径交点的情况 872 | if (y === y0 || y === y1) { 873 | dir = dir / 2 874 | } 875 | // 和线段的交点 876 | let x_ = (x0 - x1) / (y0 - y1) * (y - y0) + x0 877 | 878 | return x_ === x ? 879 | Infinity : 880 | x_ > x ? dir : 0 881 | } 882 | ``` 883 | 对于`lineTo`、`rect`、`drawImage`和`closePath`,传入对应参数即可。至此,我们的矩形元素,已经可以检测是否包含了。离成功不远啦!给我冲! 884 | ### arc 885 | 圆弧则稍微复杂一些。但大体思路都相似,具体的判断则类似于弧的描边判断。 886 | 首先考虑圆或者近似圆的情况。 887 | 888 | ![](./images/v6/arc-fill.png) 889 | 890 | > 如果点在圆外,那么水平线必然穿过两边,最后返回0(如果水平线与圆相切,同样可以认为是0)。 891 | 892 | > 如果点在圆内或圆上,考虑右半边弧的方向即可,顺时针返回-1,逆时针返回1。 893 | 然后是弧。 894 | 895 | ![](./images/v6/arc-fill_1.png) 896 | 897 | ![](math/v6/公式_7.png) 898 | 899 | 代码如下: 900 | ```typescript 901 | // contain.ts 902 | let roots = [] 903 | /** 904 | * 对弧非零规则检测 905 | */ 906 | function windingArc ( 907 | cx: number, cy: number, r: number, startAngle: number, endAngle: number, 908 | anticlockwise: number, x: number, y: number 909 | ) { 910 | y -= cy 911 | if (y > r || y < -r) { 912 | return 0 913 | } 914 | let tmp = Math.sqrt(r * r - y * y) 915 | 916 | roots[0] = -tmp 917 | roots[1] = tmp 918 | 919 | let diff = Math.abs(startAngle - endAngle); 920 | if (diff < 1e-4) { 921 | return 0 922 | } 923 | if (diff % PI2 < 1e-4) { 924 | // 圆 925 | startAngle = 0 926 | endAngle = PI2 927 | let dir = anticlockwise ? 1 : -1 928 | if (tmp === r) { 929 | // 圆上 930 | return Infinity 931 | } else if (x > roots[0] + cx && x < roots[1] + cx) { 932 | return dir 933 | } 934 | else { 935 | return 0 936 | } 937 | } 938 | // 起始点和终点的纵坐标,判定水平线是否路过端点 939 | let startY = Math.sin(startAngle) * r + cy 940 | let endY = Math.sin(endAngle) * r + cy 941 | if (anticlockwise) { 942 | let tmp = startAngle 943 | startAngle = normalizeRadian(endAngle) 944 | endAngle = normalizeRadian(tmp) 945 | } 946 | else { 947 | startAngle = normalizeRadian(startAngle) 948 | endAngle = normalizeRadian(endAngle) 949 | } 950 | if (startAngle > endAngle) { 951 | endAngle += PI2 952 | } 953 | 954 | let w = 0 955 | for (let i = 0; i < 2; i++) { 956 | let x_ = roots[i] 957 | if (x_ + cx > x) { 958 | let angle = Math.atan2(y, x_) 959 | let dir = anticlockwise ? 1 : -1 960 | if (angle < 0) { 961 | angle = PI2 + angle 962 | } 963 | if ( 964 | (angle >= startAngle && angle <= endAngle) 965 | || (angle + PI2 >= startAngle && angle + PI2 <= endAngle) 966 | ) { 967 | // 弧上 968 | if (tmp === r) { 969 | return Infinity 970 | } 971 | // 端点处 972 | if ((y + cy) === startY || (y + cy) === endY) { 973 | dir /= 2 974 | } 975 | // 左半边方向反转 976 | if (angle > Math.PI / 2 && angle < Math.PI * 1.5) { 977 | dir = -dir 978 | } 979 | w += dir 980 | } 981 | } 982 | } 983 | return w; 984 | } 985 | ``` 986 | 不过在应用上和线段则开始不同,原因和之前所说圆弧的包围盒判断的原因相同,简单来说就是需要考虑上一段路径终点和闭合路径的情况。 987 | ```typescript 988 | // ... 989 | function windingPreToStart() { 990 | if (!isStroke) { 991 | nozero += windingLine( 992 | prePathFinal[0], 993 | prePathFinal[1], 994 | pathStartPoint[0], 995 | pathStartPoint[1], 996 | x, 997 | y 998 | ) 999 | } 1000 | } 1001 | case PathType.arc: 1002 | if (isStroke) { 1003 | } else { 1004 | nozero += windingArc( 1005 | params[0], 1006 | params[1], 1007 | params[2], 1008 | params[3], 1009 | params[4], 1010 | params[5], 1011 | x, 1012 | y 1013 | ) 1014 | } 1015 | pathStartPoint[0] = Math.cos(params[3]) * params[2] + params[0] 1016 | pathStartPoint[1] = Math.sin(params[3]) * params[2] + params[1] 1017 | windingPreToStart() 1018 | prePathFinal[0] = Math.cos(params[4]) * params[2] + params[0] 1019 | prePathFinal[1] = Math.sin(params[4]) * params[2] + params[1] 1020 | break 1021 | //...省略其它也要加windingPreToStart的代码 1022 | //... 1023 | // 填充判定需要认为已经闭合路径了 1024 | if (!isStroke) { 1025 | nozero += windingLine( 1026 | prePathFinal[0], 1027 | prePathFinal[1], 1028 | start[0], 1029 | start[1], 1030 | x, 1031 | y 1032 | ) 1033 | } 1034 | ``` 1035 | ### quadratic 1036 | 二次贝塞尔曲线和圆弧也差不多。回顾之前的图可以发现: 1037 | 1038 | ![](./images/v6/curve-fill.png) 1039 | > 对于待检测点(x, y),可以求出其水平线和曲线的交点(x', y'),对于x' > x的交点,可以进行进一步检测 1040 | > 如果曲线的极值不在范围内,则此曲线是单调的,根据起点和终点的纵坐标比较,则可得出结果 1041 | > 否则根据极值的位置进行两段比较 1042 | > 同样的,对于经过曲线端点的情况将结果除以2,对于曲线上的点返回无穷大 1043 | ```typescript 1044 | // contain.ts 1045 | /** 1046 | * 对二次贝塞尔曲线非零规则检测 1047 | */ 1048 | function windingQuadratic( 1049 | x0: number, y0: number, x1: number, y1: number, x2: number, y2: number, x: number, y: number 1050 | ) { 1051 | if ( 1052 | (y > y0 && y > y1 && y > y2) 1053 | || (y < y0 && y < y1 && y < y2) 1054 | ) { 1055 | return 0 1056 | } 1057 | let nRoots = quadraticRootAt(y0, y1, y2, y, roots) 1058 | // 没有交点 1059 | if (nRoots === 0) { 1060 | return 0 1061 | } 1062 | else { 1063 | let t = quadraticExtremum(y0, y1, y2) 1064 | // 极值在范围内,两种单调性 1065 | if (t >= 0 && t <= 1) { 1066 | let w = 0 1067 | let y_ = quadraticAt(y0, y1, y2, t) 1068 | for (let i = 0; i < nRoots; i++) { 1069 | // 端点 1070 | let unit = (roots[i] === 0 || roots[i] === 1) ? 0.5 : 1 1071 | 1072 | let x_ = quadraticAt(x0, x1, x2, roots[i]) 1073 | if (x_ < x) { 1074 | continue 1075 | } 1076 | // 曲线上 1077 | if (x_ === x) { 1078 | return Infinity 1079 | } 1080 | if (roots[i] < t) { 1081 | w += y_ < y0 ? unit : -unit 1082 | } 1083 | else { 1084 | w += y2 < y_ ? unit : -unit 1085 | } 1086 | } 1087 | return w 1088 | } 1089 | // 极值在范围外,只有一种单调性 1090 | else { 1091 | // 端点 1092 | let unit = (roots[0] === 0 || roots[0] === 1) ? 0.5 : 1 1093 | 1094 | let x_ = quadraticAt(x0, x1, x2, roots[0]) 1095 | // 曲线上 1096 | if (x_ === x) { 1097 | return Infinity 1098 | } 1099 | if (x_ < x) { 1100 | return 0 1101 | } 1102 | return y2 < y0 ? unit : -unit 1103 | } 1104 | } 1105 | } 1106 | ``` 1107 | ### cubic 1108 | 三次贝塞尔曲线同理,不再多说。 1109 | ```typescript 1110 | // contain.ts 1111 | function swapExtrema () { 1112 | let tmp = extrema[0] 1113 | extrema[0] = extrema[1] 1114 | extrema[1] = tmp 1115 | } 1116 | /** 1117 | * 对三次贝塞尔曲线非零规则检测 1118 | */ 1119 | function windingCubic ( 1120 | x0: number, y0: number, x1: number, y1: number, 1121 | x2: number, y2: number, x3: number, y3: number, 1122 | x: number, y: number 1123 | ) { 1124 | // Quick reject 1125 | if ( 1126 | (y > y0 && y > y1 && y > y2 && y > y3) 1127 | || (y < y0 && y < y1 && y < y2 && y < y3) 1128 | ) { 1129 | return 0 1130 | } 1131 | let nRoots = cubicRootAt(y0, y1, y2, y3, y, roots) 1132 | // 不经过 1133 | if (nRoots === 0) { 1134 | return 0 1135 | } 1136 | else { 1137 | let w = 0 1138 | let nExtrema = -1 1139 | let y0_ 1140 | let y1_ 1141 | for (let i = 0; i < nRoots; i++) { 1142 | let t = roots[i] 1143 | // 路过端点 1144 | let unit = (t === 0 || t === 1) ? 0.5 : 1 1145 | 1146 | let x_ = cubicAt(x0, x1, x2, x3, t) 1147 | if (x_ < x) { 1148 | continue 1149 | } 1150 | // 曲线上 1151 | if (x_ === x) { 1152 | return Infinity 1153 | } 1154 | if (nExtrema < 0) { 1155 | nExtrema = cubicExtrema(y0, y1, y2, y3, extrema); 1156 | if (extrema[1] < extrema[0] && nExtrema > 1) { 1157 | swapExtrema() 1158 | } 1159 | y0_ = cubicAt(y0, y1, y2, y3, extrema[0]); 1160 | if (nExtrema > 1) { 1161 | y1_ = cubicAt(y0, y1, y2, y3, extrema[1]) 1162 | } 1163 | } 1164 | if (nExtrema === 2) { 1165 | // 分成三段单调函数 1166 | if (t < extrema[0]) { 1167 | w += y0_ < y0 ? unit : -unit 1168 | } 1169 | else if (t < extrema[1]) { 1170 | w += y1_ < y0_ ? unit : -unit 1171 | } 1172 | else { 1173 | w += y3 < y1_ ? unit : -unit 1174 | } 1175 | } 1176 | else { 1177 | // 分成两段单调函数 1178 | if (t < extrema[0]) { 1179 | w += y0_ < y0 ? unit : -unit 1180 | } 1181 | else { 1182 | w += y3 < y0_ ? unit : -unit 1183 | } 1184 | } 1185 | } 1186 | return w 1187 | } 1188 | } 1189 | ``` 1190 | ### 关于arcTo和椭圆 1191 | 一方面这些功能已经足够使用,另一方面写这些已经很累了,我就不写了,有需要的童鞋请自行补充。 1192 | ### 一些bug 1193 | 这一块对我来说还是有点复杂,因此可能出现没有注意到或思虑不全的`bug`。还有就是前面也说了,本文和代码是同步进行的,这一部分删删改改过于反复,可能会有文中代码和实际代码不符的情况,请以实际代码为准。 1194 | > 为什么不仔细校对?已经大概校对过了。剩下的即使有错误,也不影响理解。 1195 | ## 事件 1196 | 有了以上基础,就可以完善我们的事件系统了。主要是对`mouseleave`和`mouseenter`事件做特殊处理。 1197 | ```typescript 1198 | //domHandler.ts 1199 | /** 1200 | * 处理函数 1201 | */ 1202 | const handlers = { 1203 | // ... 1204 | mousemove (e: XrEvent, xel: XElement, isContain: boolean) { 1205 | // 鼠标已离开元素 1206 | if (xel.hover && !isContain) { 1207 | xel.dispatch('mouseleave', normalizeEvent(e, 'mouseleave', xel)) 1208 | // 继续移动 1209 | } else if (xel.hover && isContain) { 1210 | xel.dispatch('mousemove', normalizeEvent(e, 'mousemove', xel)) 1211 | // 初次进入 1212 | } else if (!xel.hover && isContain) { 1213 | xel.dispatch('mouseenter', normalizeEvent(e, 'mouseenter', xel)) 1214 | } 1215 | } 1216 | } 1217 | export default function createDomHandler (dom: HTMLElement, stage: Stage) { 1218 | mouseEvents.forEach(eventName => { 1219 | let handler = momuseEventHandlers[eventName] = (e: MouseEvent) => { 1220 | // ... 1221 | for (; i >= 0; i -= 1) { 1222 | xel = xelements[i] 1223 | let isContain = xel.contain(xrEvent.x, xrEvent.y) 1224 | if (isContain) { 1225 | // 对于剩下的元素,可以直接设置hover为false来重置,不必再判断 1226 | // 并且mouseleave要在mouseenter前触发 1227 | for (i -= 1; i >= 0; i -= 1) { 1228 | if (eventName === 'mousemove') { 1229 | handlers[eventName](xrEvent, xelements[i], false) 1230 | } 1231 | xelements[i].hover = false 1232 | } 1233 | } 1234 | if (isContain || (xel.hover && eventName === 'mousemove')) { 1235 | handlers[eventName](xrEvent, xel, isContain) 1236 | } 1237 | // 为元素添加此属性以便做`mouseleave`等判断 1238 | xel.hover = isContain 1239 | } 1240 | } 1241 | }) 1242 | } 1243 | ``` 1244 | 更多事件和移动端事件请自行摸索。 1245 | ## 性能 1246 | 还记得最开始不用官方`api`来检测的原因吗?运行同样的测试,可以发现时间在`5ms`以内,如果鼠标上有元素,一般在`1ms`以内,基本满足需求。 1247 | ## 应用 1248 | 如何应用呢? 1249 | ### 小小应用——悬浮指针 1250 | 现在考虑这样一个需求,要求鼠标在元素上时指针变为可点击形态,即`pointer`,参考`zrender`,我们将它设计为所有元素都默认如此,在`XElement.style`中添加`cursor`属性。很容易想到在每个元素创建时为其添加相关的事件处理。但同时也能想到的另一个模式就是冒泡,让总代理来处理这些重复触发多次的事。为此重写`XElement.dispatch`,并将`dispatch`加入继承的排除属性列表中。然后让`XRender`继承`Eventful`。可以发现这样的话鼠标不在元素上`xr`就无法获取到事件了,所幸修改起来并不难。 1251 | ```typescript 1252 | class XElement implements Transform, Eventful { 1253 | // ... 1254 | dispatch(event: XrEventType, params?: XrEvent): void { 1255 | Eventful.prototype.dispatch.call(this, event, params) 1256 | if (this.parent && (this.parent.parent || this.parent._xr)) { 1257 | this.parent.dispatch(event, params) 1258 | } else { 1259 | this._xr.dispatch(event, params) 1260 | } 1261 | } 1262 | } 1263 | inherit(XElement, Eventful, ['dispatch']) 1264 | 1265 | // domHandlers.ts 1266 | export class DomHandler extends Eventful { 1267 | } 1268 | function createDomHandler (dom: HTMLElement, stage: Stage) { 1269 | const mouseEventsHandlers = {} 1270 | const domHandler = new DomHandler(mouseEventsHandlers, dom) 1271 | mouseEvents.forEach(eventName => { 1272 | let handler = mouseEventsHandlers[eventName] = (e: MouseEvent) => { 1273 | // ... 1274 | for (; i >= 0; i -= 1) { 1275 | // ... 1276 | } 1277 | if (!xrEvent.target) { 1278 | domHandler.dispatch(eventName, xrEvent) 1279 | } 1280 | } 1281 | dom.addEventListener(eventName, handler) 1282 | }) 1283 | } 1284 | // Xrender.ts 1285 | class XRender implements Eventful { 1286 | constructor (dom: string | HTMLElement, opt: XRenderOptions = {}) { 1287 | extendsClass(this, Eventful) 1288 | // ... 1289 | this.initEventHandler() 1290 | } 1291 | initDomHandler () { 1292 | const domHandler = this.domHandler = createDomHandler(this.painter.layerContainer, this.stage) 1293 | let domEventHandlers = this.domHandler.domEventsHandlers 1294 | for (let eventName in domEventHandlers) { 1295 | domHandler.on(eventName as XrEventType, e => { 1296 | this.dispatch(eventName as XrEventType, e) 1297 | }) 1298 | } 1299 | } 1300 | /** 1301 | * 对一些事件进行初始化,比如鼠标样式的变化 1302 | */ 1303 | initEventHandler () { 1304 | this.on('mouseenter', () => { 1305 | this.setCursor(e.target.style.cursor) 1306 | }) 1307 | this.on('mouseleave', () => { 1308 | this.setCursor('default') 1309 | }) 1310 | } 1311 | /** 1312 | * 设置鼠标样式 1313 | */ 1314 | setCursor (cursor = 'pointer') { 1315 | this.painter.layerContainer.style.cursor = cursor 1316 | } 1317 | } 1318 | ``` 1319 | 移动鼠标,可以发现鼠标样式的变化。 1320 | 1321 | ![](./images/v6/cursor-pointer.gif) 1322 | 1323 | > 可以看到在`domHandler`中可以直接触发`xr`所监听的事件,即让`xr`的`on`等方法代理到`domHandler`上。不这么做而多此一举的原因是想让事件触发更符合冒泡。 1324 | 1325 | ### 小小应用——拖曳 1326 | 另一个常用的应用是拖曳,很容易实现它。 1327 | ```typescript 1328 | class XElement { 1329 | /** 1330 | * 是否开启可拖曳 1331 | */ 1332 | dragable = false 1333 | constructor () { 1334 | this.initEventHandler() 1335 | } 1336 | initEventHandler () { 1337 | this.initDragEvent() 1338 | } 1339 | initDragEvent () { 1340 | let lastX = 0 1341 | let lastY = 0 1342 | let draging = false 1343 | this.on('mousedown', e => { 1344 | if (!this.dragable) { 1345 | return 1346 | } 1347 | draging = true 1348 | lastX = e.x 1349 | lastY = e.y 1350 | this._xr.setCursor('move') 1351 | }) 1352 | this.on('mousemove', e => { 1353 | if (!draging || !this.dragable) { 1354 | return 1355 | } 1356 | let xDiff = e.x - lastX 1357 | let yDiff = e.y - lastY 1358 | let xel = e.target 1359 | xel.attr({ 1360 | position: [xel.position[0] += xDiff, xel.position[1] += yDiff] 1361 | }) 1362 | lastX = e.x 1363 | lastY = e.y 1364 | this._xr.setCursor(this.style.cursor) 1365 | }) 1366 | this.on('mouseup', e => { 1367 | if (!this.dragable) { 1368 | return 1369 | } 1370 | draging = false 1371 | }) 1372 | this.on('mouseleave', e => { 1373 | if (!this.dragable) { 1374 | return 1375 | } 1376 | draging = false 1377 | this._xr.setCursor('default') 1378 | }) 1379 | } 1380 | } 1381 | ``` 1382 | 可以发现已经实现拖曳了(`move`样式是后面加的,就不重新截图了)。 1383 | ![](./images/v6/drag-el.gif) 1384 | 1385 | #### 变换顺序 1386 | 虽然看起来一切正常,但是如果调整元素的旋转角度,再进行拖曳的话,就会发现移动的轨迹很奇怪。这是因为之前是先旋转再平移的,因为拖曳的功能,我们需要调整变换的顺序,**先平移,再旋转**,要修改`XElment.setTransform`,`Group.resumeTransform`,`getTransformCord`,调整过程这里就不写了,相信大家可以很轻松地搞定。 1387 | > 同样的,也可以不变换顺序,只需要对拖曳的向量做变换即可,此处就不用这种方式了。 1388 | #### Group的拖曳 1389 | 另一个常见的需求是对一个组内所有元素进行拖曳。要做到这一点,首先要明确的是,一个`Group`是矩形的,并且它的形状和大小就是组内元素包围盒的并集——如果子元素相对定位元素不是自身,那么不计入。但是需要注意的是,计算组的包围盒时,子元素如果有`transform`,那么它的包围盒同样需要进行`transform`变换,最后才能获取到子元素准确的包围盒。 1390 | > 对于旋转,不是元素本身旋转,包围盒就需要旋转,比如圆绕自身圆心旋转。但是如果考虑到这一点的话,事情会变得非常复杂,也很难去计算。因此这里就忽略这些差异性了(当然我不会说因为`zrender`没做所以我也不做)。 1391 | 1392 | 如何求包围盒变换后形成的新包围盒?对矩形的四个点分别应用变换,再求取四个点的包围盒即可。创建`util/boundingRect.ts` 1393 | ```typescript 1394 | import { Transform } from '../xElements/XElement' 1395 | import BoundingRect from '../BoundingRect' 1396 | /** 1397 | * 对包围盒应用变换 1398 | * 思路是对每一个点应用变换,然后重新求包围盒 1399 | */ 1400 | export function rectTransform (rect: BoundingRect,transform: Transform) { 1401 | let x0 = rect.x 1402 | let y0 = rect.y 1403 | let x1 = rect.x + rect.width 1404 | let y1 = rect.y + rect.height 1405 | let point1 = [x0, y0] 1406 | let point2 = [x1, y0] 1407 | let point3 = [x1, y1] 1408 | let point4 = [x0, y1] 1409 | pointTransform(point1, transform) 1410 | pointTransform(point2, transform) 1411 | pointTransform(point3, transform) 1412 | pointTransform(point4, transform) 1413 | let min = [0, 0] 1414 | let max = [0, 0] 1415 | minPoints(min, point1, point2, point3, point4) 1416 | maxPoints(max, point1, point2, point3, point4) 1417 | 1418 | return new BoundingRect( 1419 | min[0], 1420 | min[1], 1421 | max[0] - min[0], 1422 | max[1] - min[1] 1423 | ) 1424 | } 1425 | function pointTransform (point: number[], transform: Transform) { 1426 | let x = point[0] 1427 | let y = point[1] 1428 | // 所有距离都要乘以缩放系数 1429 | let scaleX = transform.scale[0] 1430 | let scaleY = transform.scale[1] 1431 | // 绕中心点缩放 1432 | x = x * scaleX 1433 | y = y * scaleY 1434 | // 平移 1435 | x += transform.position[0] 1436 | y += transform.position[1] 1437 | // 得出它绕中心点旋转对应角度后的坐标 1438 | // 证明过程参考前文 1439 | let sinRotation = Math.sin(transform.rotation) 1440 | let cosRotation = Math.cos(transform.rotation) 1441 | let x2ox = x - transform.origin[0] 1442 | let y2oy = y - transform.origin[1] 1443 | x = x2ox * cosRotation - y2oy * sinRotation + transform.origin[0] 1444 | y = x2ox * sinRotation + y2oy * cosRotation + transform.origin[1] 1445 | 1446 | 1447 | point[0] = x 1448 | point[1] = y 1449 | } 1450 | function minPoints (min: number[], ...points: number[][]) { 1451 | min[0] = Math.min(...points.map(point => point[0])) 1452 | min[1] = Math.min(...points.map(point => point[1])) 1453 | } 1454 | function maxPoints (max: number[], ...points: number[][]) { 1455 | max[0] = Math.max(...points.map(point => point[0])) 1456 | max[1] = Math.max(...points.map(point => point[1])) 1457 | } 1458 | ``` 1459 | 将它挂载到`BoudingRect`上。 1460 | ```typescript 1461 | class BoudingRect { 1462 | applyTransform (transform: Transform) { 1463 | return rectTransform(this, transform) 1464 | } 1465 | } 1466 | ``` 1467 | 在之前的代码中我们是获取不到`Group`类元素的,为了能够让它对事件作出一些响应,需要改变这部分代码,同时让它排在最前面——没有具体的元素响应事件才考虑让`Group`来响应。 1468 | ```typescript 1469 | class Stage { 1470 | updateXElements (callback?: (xel: XElement) => void) { 1471 | // zIndex高的在后 1472 | // zLevel高的在后,其它按加入次序排列 1473 | // 即,最上层的就在最后面,方便事件检测时能倒序遍历 1474 | // 将group排在最前面 1475 | return this.expandXElements(callback).sort((a, b) => { 1476 | let isGroup = a.name === 'group' 1477 | let zIndex = a.zIndex - b.zIndex 1478 | return !isGroup 1479 | ? zIndex === 0 ? a.zLevel - b.zLevel : zIndex 1480 | : -1 1481 | }) 1482 | } 1483 | /** 1484 | * 展开所有元素 1485 | * 有一个副作用,目前用来设置xr 1486 | */ 1487 | expandXElements (callback?: (xel: XElement) => void) { 1488 | for (let childIndex = 0; childIndex < xElements.length; childIndex += 1) { 1489 | if (xel.stage) { 1490 | //... 1491 | // 将自身也加入,为了能够触发事件 1492 | children.push(xel) 1493 | } else { 1494 | } 1495 | } 1496 | 1497 | return list 1498 | } 1499 | } 1500 | ``` 1501 | 接着将`Group`的默认样式设为`default`。并且在从父元素获取的选项中去掉`dragable`。在拖曳相关事件中判断`this`和`e.target`是否相同(事件会冒泡)。 1502 | 1503 | 尝试开启`Group.dragable`,测试可以发现能够正常拖曳单个元素和整个组的元素。 1504 | ![](./images/v6/drag-group.gif)。 1505 | 1506 | 至此,我们的事件处理就差不多完成了,虽然可能还有许多瑕疵,但是应该说了讲清了大概的原理和实现过程。 1507 | ## 小结 1508 | 这个版本中做了许多事,但最后总结起来就是添加了事件处理系统。 1509 | 1510 | ## V7预览 1511 | [文字](./Version7.md)。 1512 | -------------------------------------------------------------------------------- /Version6.md: -------------------------------------------------------------------------------- 1 | # 从零打造Echarts —— V6 事件绑定和包围盒 2 | 本文开始v6版本。 3 | ## 回顾v5 4 | 在v5版本中我们添加了分组、分层以及逐帧绘制的功能。 5 | ## 开始 6 | 经过前5个版本的打造,现在的`xrender`已经具有一个`canvas`库的雏形了,已经可以利用它(或者补充它之后)做到很多。但是这些功能都只能在代码里写死,无法根据动作来做出响应,如点击图形后放大。而这,恰恰是一个`canvas`库,非常重要,也是不可或缺的一部分。 7 | ### 思路 8 | 首先我们要知道,对于`canvas`内的任何事件,都只发生在`canvas`元素上——浏览器感知不到我们的图形,所以要想实现对图形做到像`dom`元素的事件监听,只能自己来实现。很容易想象出这个流程,以`click`事件为例: 9 | 1. 监听我们创建的`dom`容器的`click`事件,触发后获取鼠标的坐标,然后转换为`canvas`内部的坐标,一般以左上角为原点。 10 | 2. 遍历所有图形元素,检测该图形是否包含这一点,如果包含,触发元素的`click`事件,调用所有`click`回调。 11 | 12 | 咦,是我遗漏了什么吗,怎么只有两步?当然,很容易看出最难的就是如何检测图形是否包含该点。先略过它,我们完善其它的步骤。 13 | 14 | ### 事件管理 `Eventful` 15 | 一个常见的事件处理模型,此处不再赘述,只贴代码。如果有不懂的地方,可能需要加强一下基础。 16 | 17 | 创建`Eventful.ts`文件 18 | ```typescript 19 | type Handler = (e?: T) => void 20 | 21 | // 不考虑`handler`不是函数的情况 22 | class Eventful { 23 | /** 24 | * 事件回调 25 | */ 26 | _handlers: { 27 | [prop: string]: Handler[] 28 | } = {} 29 | /** 30 | * 监听事件 31 | */ 32 | on (event: EventType, handler: Handler) { 33 | // 绕过类型检查 34 | if (typeof event !== 'string') { 35 | return 36 | } 37 | let handlers = this._handlers 38 | if (!handlers[event]) { 39 | handlers[event] = [handler] 40 | } else { 41 | if (handlers[event].indexOf(handler) > -1) { 42 | return 43 | } 44 | handlers[event].push(handler) 45 | } 46 | 47 | } 48 | /** 49 | * 取消监听 50 | */ 51 | off (event?: EventType, handler?: Handler) { 52 | // 绕过类型检查 53 | if (typeof event !== 'string') { 54 | return 55 | } 56 | if (!event) { 57 | this._handlers = {} 58 | return 59 | } 60 | let handlers = this._handlers[event] 61 | if (!handlers) { 62 | return 63 | } 64 | if (!handler) { 65 | this._handlers[event] = [] 66 | } else { 67 | let index = handlers.indexOf(handler) 68 | if (index > -1) { 69 | handlers.splice(index, 1) 70 | } 71 | } 72 | } 73 | /** 74 | * 触发回调 75 | */ 76 | dispatch (event: EventType, params?: Params) { 77 | // 绕过类型检查 78 | if (typeof event !== 'string') { 79 | return 80 | } 81 | let handlers = this._handlers[event] 82 | if (!handlers) { 83 | return 84 | } 85 | for (let i = 0; i < handlers.length; i += 1) { 86 | handlers[i](params) 87 | } 88 | } 89 | } 90 | 91 | export default Eventful 92 | ``` 93 | 为了让元素能够直接使用`xel.on()`,一种方法是为元素添加这些方法,映射到创建的`eventful`上,另一种方法则是,让`XElement`继承它。我觉得第二种方法要方便一点。但是`ts`和`es6`都只能继承一个类,以后要继承更多的类怎么办?为此添加一下工具方法,来实现多个类的继承——属于非父子继承,只是扩展功能。 94 | ```typescript 95 | /** 96 | * 只是用来继承原型的 97 | * @example 98 | * // 两个函数结合起来用 99 | * class A { 100 | * super() { 101 | * extendsClass(this, B) 102 | * } 103 | * } 104 | * inherit(A, [B]) 105 | */ 106 | export function inherit (child: Function, parents: Function | Function[], excludeProps: string[] = []) { 107 | if (!Array.isArray(parents)) { 108 | parents = [parents] 109 | } 110 | let props: string[] 111 | let prop: string 112 | let prototype = child.prototype as Object 113 | let parentPrototype: Object 114 | for (let parentIndex = 0; parentIndex < parents.length; parentIndex += 1) { 115 | parentPrototype = parents[parentIndex].prototype 116 | props = Object.getOwnPropertyNames(parentPrototype) 117 | for (let propIndex = 0; propIndex < props.length; propIndex += 1) { 118 | prop = props[propIndex] 119 | // 使用本方法来继承一般是混合继承,而不是子类继承父类,一般不会有同名属性 120 | // 另一方面因为ts的验证,需要提前设置属性做好站位,所以即使有同名属性,也需要进行覆盖 121 | if (prop !== 'constructor' && excludeProps.indexOf(prop) === -1) { 122 | prototype[prop] = clone(parentPrototype[prop]) 123 | } 124 | } 125 | } 126 | 127 | return child 128 | } 129 | /** 130 | * 为类创建一个实例,然后将实例的属性复制给传入的上下文 131 | * 以实现多继承 132 | * 原型的继承则交给inherit 133 | * @param context 上下文,当前类 134 | * @param Parent 父类 135 | * @param args 父类的参数 136 | */ 137 | export function extendsClass (context, Parents, args = []) { 138 | if (!Array.isArray(Parents)) { 139 | Parents = [Parents] 140 | } 141 | Parents.forEach(Parent => { 142 | let instance = new Parent(...args) 143 | for (let key in instance) { 144 | if (instance.hasOwnProperty(key)) { 145 | context[key] = instance[key] 146 | } 147 | } 148 | }) 149 | } 150 | ``` 151 | > 关于多继承,没有搜到太好的办法。使用类的话无法在构造函数中调用`Class.call()`这样的方法来实现`extendsCalss`的功能;如果采用混入继承则没有找到很好的类型推论的办法,所以自己随便写了一个。当然也许有更好的办法,欢迎提出。 152 | 153 | 然后使用让`XElement`继承`Eventful`。 154 | ```typescript 155 | // 为了告诉编译器可以使用`Eventful`的属性 156 | class XElement implements EventFul { 157 | // ... 这里有一段编辑器自动添加的实现`Eventful`的代码,就不贴了 158 | // 后期会添加事件类型和参数,也就不贴了 159 | constructor (opt: XElementOptions = {}) { 160 | extendsClass(this, Eventful) 161 | this.options = opt 162 | } 163 | } 164 | inherit(XElement, Eventful) 165 | // 尝试一下 166 | let xel = new XElement() 167 | xel.on('s', (info) => { 168 | console.log(info) 169 | }) 170 | xel.dispatch('s', 'fd') 171 | // 如期工作了 172 | ``` 173 | ### 坐标映射 174 | 接下来监听容器的事件。转换坐标后判断所有元素是否包含这个点。 175 | 176 | 但是考虑这样一种情况,多个元素重叠在一起,应该如何判定?目前暂定为只对最上层的事件做响应,一是这样符合实际,二是这样可以提升性能,减少不必要的检测。为此需要改变`Stage.getAll`的排序规则。 177 | ```typescript 178 | class Stage { 179 | // ... 180 | updateXElements (callback?: (xel: XElement) => void) { 181 | // zIndex高的在后 182 | // zLevel高的在后,其它按加入次序排列 183 | // 即,最上层的就在最后面,方便事件检测时能倒序遍历 184 | return this.expandXElements(callback).sort((a, b) => { 185 | let zIndex = a.zIndex - b.zIndex 186 | return zIndex === 0 ? a.zLevel - b.zLevel : zIndex 187 | }) 188 | } 189 | } 190 | ``` 191 | 创建`domHandler.ts` 192 | ```typescript 193 | import Stage from './Stage' 194 | import XElement from './xElements/XElement' 195 | /** 196 | * 定义事件的格式 197 | */ 198 | export interface XrEvent { 199 | /** 200 | * 原始的事件信息 201 | */ 202 | rawEvent?: Event 203 | /** 204 | * x坐标 205 | */ 206 | x?: number 207 | /** 208 | * y坐标 209 | */ 210 | y?: number 211 | /** 212 | * 触发事件的元素 213 | */ 214 | target?: XElement 215 | /** 216 | * 事件类型 217 | */ 218 | type?: XrEventType 219 | } 220 | export type XrEventType = 'click' | 'mousedown' | 'mouseup' | 'mousemove' | 'mouseleave' | 'mouseenter' 221 | /** 222 | * 鼠标事件名称 223 | * 目前也只考虑鼠标事件 224 | * 大部分事件不需要做特殊处理 225 | */ 226 | const mouseEvents: XrEventType[] = [ 227 | 'click', 228 | 'mousedown', 229 | 'mouseup', 230 | // 对于mouseleave等事件,只能通过mousemove来判断 231 | 'mousemove' 232 | // ..等等 233 | ] 234 | /** 235 | * 处理函数 236 | */ 237 | const handlers = { 238 | click (e: XrEvent, xel: XElement) { 239 | xel.dispatch('click', normalizeEvent(e, 'click', xel)) 240 | }, 241 | mousemove (e: XrEvent, xel: XElement, isContain: boolean) { 242 | }, 243 | mousedown (e: XrEvent, xel: XElement) { 244 | xel.dispatch('mousedown', normalizeEvent(e, 'mousedown', xel)) 245 | }, 246 | mouseup (e: XrEvent, xel: XElement) { 247 | xel.dispatch('mouseup', normalizeEvent(e, 'mouseup', xel)) 248 | } 249 | } 250 | /** 251 | * 将事件转换为内部的事件 252 | */ 253 | function normalizeEvent (e: XrEvent, type: XrEventType, xel: XElement) { 254 | e.target = xel 255 | e.type = type 256 | 257 | return e 258 | } 259 | /** 260 | * 进行坐标转换 261 | */ 262 | function convertCoordinates (e: MouseEvent, dom: HTMLElement) { 263 | let rect = dom.getBoundingClientRect() 264 | let xrEvent: XrEvent 265 | xrEvent = { 266 | x: e.clientX - rect.left, 267 | y: e.clientY - rect.top, 268 | rawEvent: e 269 | } 270 | 271 | return xrEvent 272 | } 273 | export class DomHandler extends Eventful { 274 | domEventsHandlers: { 275 | [prop: string]: Function 276 | } = {} 277 | dom: HTMLElement 278 | constructor (handlers: { 279 | [prop: string]: Function 280 | }, dom: HTMLElement) { 281 | super() 282 | this.domEventsHandlers = handlers 283 | this.dom = dom 284 | } 285 | // 最后肯定是需要移除事件监听的 286 | dispose () { 287 | let handlers = this.domEventsHandlers 288 | for (let eventName in handlers) { 289 | this.dom.removeEventListener(eventName, handlers[eventName] as EventListenerOrEventListenerObject) 290 | } 291 | } 292 | } 293 | /** 294 | * 创建dom的事件处理 295 | */ 296 | export default function createDomHandler (dom: HTMLElement, stage: Stage) { 297 | const mouseEventsHandlers = {} 298 | const domHandler = new DomHandler(mouseEventsHandlers, dom) 299 | mouseEvents.forEach(eventName => { 300 | let handler = momuseEventHandlers[eventName] = (e: MouseEvent) => { 301 | let xrEvent = convertCoordinates(e, dom) 302 | let xelements = stage.getAll() 303 | let xel: XElement 304 | let i = xelements.length - 1 305 | // 并不是说被是包含才调用对应的处理函数 306 | // 如`mouseleave`事件,虽然鼠标离开的坐标不在元素内,但元素仍然需要触发事件 307 | for (; i >= 0; i -= 1) { 308 | xel = xelements[i] 309 | let isContain = xel.contain(xrEvent.x, xrEvent.y) 310 | if (isContain || (xel.hover && eventName === 'mousemove')) { 311 | handlers[eventName](xrEvent, xel, isContain) 312 | } 313 | // 为元素添加此属性以便做`mouseleave`等判断 314 | xel.hover = isContain 315 | if (isContain) { 316 | break 317 | } 318 | } 319 | // 对于剩下的元素,可以直接设置hover为false来重置,不必再判断 320 | for (i -= 1; i >= 0; i -= 1) { 321 | xelements[i].hover = false 322 | } 323 | } 324 | dom.addEventListener(eventName, handler) 325 | } 326 | } 327 | 328 | ``` 329 | 330 | 331 | 然后在`XRender`中添加对应代码。 332 | ```typescript 333 | class Xrender { 334 | consturctor () { 335 | // ... 336 | this.painter = new Painter(dom, stage, opt) 337 | this.initDomHandler() 338 | } 339 | /** 340 | * 初始化事件 341 | */ 342 | initDomHandler () { 343 | this.domHandler = createDomHandler(this.painter.layerContainer, this.stage) 344 | } 345 | } 346 | ``` 347 | 查看是否成功: 348 | ```typescript 349 | circle1.on('mousedown', e => { 350 | console.log(e) 351 | }) 352 | ``` 353 | 在`canvas`上按下鼠标可以看到控制台打印出了事件信息。 354 | 355 | 事件处理的骨架已经完成,接下来就是重头戏了。 356 | ### 路径包含 357 | 如何检测一个点是否被图形包含?也就是被路径包含?官方提供了这样的方法: 358 | - `isPointInPath` 359 | - `isPointInStroke` 360 | 361 | 那么它能不能满足我们的需要呢?经过测试,是可以的。而且它的兼容性也还可以,虽然在ie上存在一些问题,不过我想那无关紧要。改造`XElement`,在绘制的时候构建一个路径,填充和描边时直接使用这个路径,之后就能用来检测了。 362 | 363 | 不过虽然它能检测一个点是否在路径内,或者在描边上,但还是存在一些问题。这个问题就和`Path2D`有关了。 364 | 365 | 考虑以下代码: 366 | ```typescript 367 | ctx.translate(20, 20) 368 | ctx.rect(0, 0, 20, 20) 369 | ctx.stroke() 370 | let path = new Path2D() 371 | path.rect(0, 0, 20, 20) 372 | ctx.isPointInPath(path, 1, 2) 373 | ``` 374 | 结果会是`true`还是`false`?可以说开发这个`api`(或者设计这个标准)的人还是比较贴心的,结果是`false`,`ctx.isPointInPath(path, 20, 20)`的结果则是`true`。其实原因也不难想到,这个接口其实相当于将传入`path`绘制一遍再进行检测,自然这个时候是保留了坐标系的。 375 | 376 | 也就是说在我们检测之前需要对坐标进行一次变换——这似乎很容易,但是一个很容易想到的问题就是——回想前面说的事件处理的过程,一个事件触发时对所有图形进行包含检测,决定是否触发对应事件。如果照这么处理,以鼠标移动为例,每一次`mousemove`事件触发后,检测所有图形是否包含该坐标,就相当于对所有图形进行重绘,而且在这之前还要进行坐标系的变换,这样会有性能问题吗? 377 | 378 | 做个小小的测试。 379 | ```typescript 380 | let pathArr = [] 381 | for (let i = 0; i < 20000; i += 1) { 382 | path = new Path2D() 383 | path.rect(0, 0, i, i) 384 | path.moveTo(0, i) 385 | path.lineTo(2 * i, 20 * 2) 386 | path.bezierCurveTo(i, i + 2, i + 3, i + 4, i + 5, i + 6) 387 | pathArr.push(path) 388 | } 389 | console.time('path') 390 | for (let i = 0; i < 20000; i += 1) { 391 | ctx.translate(i, 2 + i) 392 | ctx.scale(i / i + 6, i / i + 6) 393 | ctx.isPointInPath(pathArr[i], i, i + 2) 394 | } 395 | console.timeEnd('path') 396 | ``` 397 | 20000次检测在`i5`4代笔记本u上花费的时间在90ms左右,应该说是可以接受,才怪呢。去掉变换矩阵的语句则大约花费`20ms`。这显然是不能接受的。更直观地对比,`zrender`检测时间为`5ms`左右。 398 | 399 | 简而言之,用这两个`api`来检测似乎已经行不通了——没有研究过它的原理,我也不知道为什么这么慢,猜测可能真的是重绘过。那么怎么办呢?如同`dom`慢,虚拟`dom`快一样,我们可以构造虚拟`canvas`。 400 | > 不然也不会说这是重头戏了。 401 | ### 路径代理 402 | 403 | 我们的判断是以元素为基准的,我们必须知道这个元素绘制了怎样的路径,才能进行下一步的处理。但问题是,每个元素绘制的路径都不一样,如何知道它绘制了什么样的路径呢?其实上面的`Path2D`已经给了我们启示——在绘制的时候,用有`context`绘制方法的一个代理对象代替`context`,就可获取该元素是如何绘制路径的,再经过一些处理,就能还原此路径。 404 | 405 | 那么思路其实就比较明确了,创建一个类似`Path2D`的代理对象,它保存着绘制路径的过程,并可以还原这个路径,同时,它还提供了类似`isPointInPath`这样的方法,来判断某点是否被路径,即元素包含,至于如何判断我们稍后再说。 406 | 407 | 创建`Path.ts`文件,如下: 408 | ```typescript 409 | /** 410 | * 路径代理 411 | * 实现了context绘制路径的方法并 412 | * 保存元素绘制的路径 413 | * 并判断是否包含某个点 414 | */ 415 | class Path { 416 | /** 417 | * 保存着路径数据,格式先不管 418 | */ 419 | data = [] 420 | /** 421 | * 真正绘制的上下文 422 | */ 423 | _ctx: CanvasRenderingContext2D 424 | constructor (ctx?: CanvasRenderingContext2D) { 425 | // 可以使用beginPath重置 426 | this._ctx = ctx 427 | } 428 | /** 429 | * 传入ctx,并开始路径绘制,做一些重置操作 430 | */ 431 | start (ctx?: CanvasRenderingContext2D) { 432 | if (ctx) [ 433 | this._ctx = ctx 434 | ] 435 | // 重置数据 436 | } 437 | /** 438 | * 添加数据 439 | */ 440 | addData () { 441 | 442 | } 443 | // 以下方法调用ctx对应方法即可 444 | // 约定ctx已经存在 445 | arc(x: number, y: number, radius: number, startAngle: number, endAngle: number, anticlockwise?: boolean): void { 446 | this.addData() 447 | this._ctx.arc(x, y, radius, startAngle, endAngle, anticlockwise) 448 | } 449 | arcTo(x1: number, y1: number, x2: number, y2: number, radius: number): void { 450 | this.addData() 451 | this._ctx.arcTo(x1, y1, x2, y2, radius) 452 | } 453 | bezierCurveTo(cp1x: number, cp1y: number, cp2x: number, cp2y: number, x: number, y: number): void { 454 | this.addData() 455 | this._ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y) 456 | } 457 | closePath(): void { 458 | this.addData() 459 | this._ctx.closePath() 460 | } 461 | ellipse(x: number, y: number, radiusX: number, radiusY: number, rotation: number, startAngle: number, endAngle: number, anticlockwise?: boolean): void { 462 | this.addData() 463 | this._ctx.ellipse(x, y, radiusX, radiusY, rotation, startAngle, endAngle, anticlockwise) 464 | } 465 | lineTo(x: number, y: number): void { 466 | this.addData() 467 | this._ctx.lineTo(x, y) 468 | } 469 | moveTo(x: number, y: number): void { 470 | this.addData() 471 | this._ctx.moveTo(x, y) 472 | } 473 | quadraticCurveTo(cpx: number, cpy: number, x: number, y: number): void { 474 | this.addData() 475 | this._ctx.quadraticCurveTo(cpx, cpy, x, y) 476 | } 477 | rect(x: number, y: number, w: number, h: number): void { 478 | this.addData() 479 | this._ctx.rect(x, y, w, h) 480 | } 481 | drawImage(img: CanvasImageSource, dx: number, dy: number, dw?: number, dh?: number, sx?: number, sy?: number, sw?: number, sh?: number) { 482 | this.addData() 483 | if (!sx) { 484 | this._ctx.drawImage(img, dx, dy, dw, dh) 485 | } else { 486 | // 参数名称有错 487 | this._ctx.drawImage(img, dx, dy, dw, dh, sx, sy, sw, sh) 488 | } 489 | } 490 | /** 491 | * 当前路径是否包含某个点 492 | */ 493 | contain (x: number, y: number) { 494 | return true 495 | } 496 | } 497 | 498 | 499 | export default Path 500 | 501 | ``` 502 | 然后为`XElement`添加一个`path`,并将其传入`render`中。 503 | ```typescript 504 | class XElement { 505 | // ... 506 | path: Path 507 | constructor (opt: XElementOptions = {}) { 508 | // ... 509 | this.path = new Path() 510 | } 511 | // ... 512 | /** 513 | * 是否包含某个点 514 | */ 515 | contain (x: number, y: number) { 516 | // 触发事件时可能还没有调用refresh 517 | if (!this.path._ctx) { 518 | return 519 | } 520 | return this.path.contain(x, y) 521 | } 522 | refresh (ctx: CanvasRenderingContext2D) { 523 | this.beforeRender(ctx) 524 | this.path.start(ctx) 525 | this.render(this.path) 526 | this.afterRender(ctx) 527 | } 528 | } 529 | ``` 530 | 保存,之前的代码还是能正常运转,说明没有问题。 531 | 532 | ### 路径代理——数据结构 533 | 接下来设计`data`的数据结构。 534 | 535 | 保存数据的目的是为了能够还原路径,所以需要`data`告诉我们,调用了哪些函数(下文中有时也称为命令)来绘制路径,还需要知道调用函数的参数。 536 | 537 | 很容易想到可以这么做。 538 | ```typescript 539 | const pathData = { 540 | type: 'rect', 541 | params: [0, 0, 20, 20] 542 | } 543 | ``` 544 | 这段数据的意思是当前元素调用过`rect`方法,参数是(0, 0, 20, 20)。当然,也可以这样。 545 | ```typescript 546 | const pathData = { 547 | type: 'rect', 548 | params: { 549 | x: 0, 550 | y: 0, 551 | w: 20, 552 | h: 20 553 | } 554 | } 555 | // 或者 556 | const pathData = ['rect', 0, 0, 20, 20] 557 | // 甚至,调用多个方法时 558 | data = ['rect', 0, 0, 20, 20, 'moveTo', 10, 10] 559 | ``` 560 | 采用哪一种?暂时决定采用最开始的结构——它并不是最简便的,也不是最容易理解的,但是居于二者之间。 561 | 562 | 在数据上我们就可以写出如下代码: 563 | ```typescript 564 | /** 565 | * 路径类型,即函数名称 566 | */ 567 | enum PathType { 568 | arc = 1, 569 | arcTo, 570 | bezierCurveTo, 571 | closePath, 572 | ellipse, 573 | lineTo, 574 | moveTo, 575 | quadraticCurveTo, 576 | rect, 577 | drawImage 578 | } 579 | interface PathData { 580 | type: PathType 581 | params: any[] 582 | } 583 | 584 | class Path { 585 | start () { 586 | //... 587 | // 重置数据 588 | this.data = [] 589 | } 590 | // 以它为例 591 | drawImage(img: CanvasImageSource, dx: number, dy: number, dw?: number, dh?: number, sx?: number, sy?: number, sw?: number, sh?: number) { 592 | if (!sx) { 593 | this._ctx.drawImage(img, dx, dy, dw, dh) 594 | this.addData(PathType.drawImage, dx, dy, dw, dh) 595 | } else { 596 | // 参数名称有错 597 | this._ctx.drawImage(img, dx, dy, dw, dh, sx, sy, sw, sh) 598 | this.addData(PathType.drawImage, sx, sy, sw, sh) 599 | } 600 | } 601 | } 602 | ``` 603 | ### 包围盒 604 | 在判断路径是否包含某个点时,我们可以先进行粗略地判断,粗略地判断通过了再进行详细地判断,图形较多时可以很好地减少比较时间,提高性能。 605 | 606 | 这个粗略的判断就是包围盒。简单来说就是可以用一个矩形来包裹住元素,如果待判断的点在矩形内,则认为有可能被路径包含,再进行详细地判断。否则认为不被包含。而判断一个点是否在矩形内,是非常简单的。 607 | 608 | 为此我们创建`BoundingRect.ts`来构造包围盒。 609 | 610 | 它需要的参数和绘制矩形的参数相同。 611 | 612 | ```typescript 613 | /** 614 | * 构造包围盒 615 | */ 616 | class BoundingRect { 617 | x = 0 618 | y = 0 619 | width = 0 620 | height = 0 621 | constructor (x: number, y: number, width: number, height: number) { 622 | // 宽高可以为负数 623 | if (width < 0) { 624 | x += width 625 | width = -width 626 | } 627 | if (height < 0) { 628 | y += height 629 | height = -height 630 | } 631 | this.x = x 632 | this.y = y 633 | this.width = width 634 | this.height = height 635 | } 636 | /** 637 | * 判定点是否在包围盒内 638 | */ 639 | contain (x: number, y: number) { 640 | return x >= this.x 641 | && x <= this.x + this.width 642 | && y >= this.y 643 | && y <= this.y + this.height 644 | } 645 | } 646 | 647 | export default BoundingRect 648 | 649 | ``` 650 | 问题是如何获取元素的包围盒?可以看到一个元素是由一个或多个绘制方法绘制而成,而每一个绘制过程都已经保存在`data`中了。可以对每一个绘制过程分别求包围盒,然后求出它们的并集,此并集是该元素的包围盒。示意图如下: 651 | ![](./images/v6/box.png) 652 | 653 | > 后面进行包含判断也同理。 654 | 655 | 为`BoundingRect`添加`union`方法求并集。 656 | ```typescript 657 | class BoundingRect { 658 | /** 659 | * 求和另一个包围盒的交集 660 | * 此包围盒不需要是`BoundingRect`的实例,有几个属性即可 661 | * 同时,直接修改当前包围盒,而不是新建一个 662 | */ 663 | union (rect: {x : number, y: number, width: number, height: number}) { 664 | if (rect.width < 0) { 665 | rect.x += rect.width 666 | rect.width = -rect.width 667 | } 668 | if (rect.height < 0) { 669 | rect.y += rect.height 670 | rect.height = -rect.height 671 | } 672 | // 对于初始宽高为0的情况直接设置 673 | if (this.width <= 0 || this.height <= 0) { 674 | this.x = rect.x 675 | this.y = rect.y 676 | this.width = rect.width 677 | this.height = rect.height 678 | return 679 | } 680 | let x = Math.min(rect.x, this.x) 681 | let y = Math.min(rect.y, this.y) 682 | 683 | this.width = Math.max( 684 | this.x + this.width, 685 | rect.x + rect.width 686 | ) - x 687 | this.height = Math.max( 688 | this.y + this.height, 689 | rect.y + rect.height 690 | ) - y 691 | this.x = x 692 | this.y = y 693 | } 694 | } 695 | ``` 696 | ### 求取包围盒 697 | 接下来为`Path`添加`rect`属性和`getBoundingRect`方法,来求取包围盒。 698 | ```typescript 699 | class Path { 700 | _rect: BoundingRect 701 | /** 702 | * 求取包围盒 703 | */ 704 | getBoundingRect () { 705 | /** 706 | * 分别保存着xy的最小和最大值 707 | */ 708 | let min = [0, 0] 709 | let max = [0, 0] 710 | // 重置包围盒 711 | let rect: BoundingRect 712 | let data = this.data 713 | let pathData: PathData 714 | let params: any[] 715 | // 遍历绘制过程,分别求取包围盒 716 | for (let i = 0; i < data.length; i += 1) { 717 | pathData = data[i] 718 | params = pathData.params 719 | // 根据绘制方法的不同用不同的计算方式 720 | switch (pathData.type) { 721 | case PathType.arc: 722 | break 723 | case PathType.arcTo: 724 | break 725 | case PathType.bezierCurveTo: 726 | break 727 | case PathType.lineTo: 728 | break 729 | case PathType.moveTo: 730 | break 731 | case PathType.quadraticCurveTo: 732 | break 733 | case PathType.rect: 734 | break 735 | case PathType.drawImage: 736 | break 737 | default: 738 | break 739 | } 740 | if (rect) { 741 | rect.union({ 742 | x: min[0], 743 | y: min[1], 744 | width: max[0] - min[0], 745 | height: max[1] - min[1] 746 | }) 747 | } else { 748 | rect = new BoundingRect( 749 | min[0], 750 | min[1], 751 | max[0] - min[0], 752 | max[1] - min[1]) 753 | } 754 | } 755 | 756 | return rect 757 | } 758 | } 759 | 760 | ``` 761 | 如何求每一段绘制过程的包围盒?找到这个过程中`x` `y`的最大最小值即可。有了记录的数据,很容易做到这一点。以矩形为第一例(图片同理,不再单独说明): 762 | ```typescript 763 | case PathType.rect: 764 | min[0] = params[0] // x 765 | min[1] = params[1] // y 766 | max[0] = params[0] + params[2] // w 767 | max[1] = params[1] + params[3] // h 768 | break 769 | ``` 770 | 就矩形而言的话,到此就可以判断了。尝试一下。首先更改`Path.contain`: 771 | ```typescript 772 | /** 773 | * 当前路径是否包含某个点 774 | */ 775 | contain (x: number, y: number) { 776 | if (!this._rect) { 777 | this._rect = this.getBoundingRect() 778 | } 779 | if (this._rect.contain(x, y)) { 780 | return true 781 | } 782 | } 783 | ``` 784 | 然后在矩形上监听事件 785 | ```typescript 786 | rect.on('mousedown', e => { 787 | console.log(e) 788 | }) 789 | ``` 790 | 然后点击画布其它地方,发现没有打印语句,点击矩形区域,出现了打印语句——才怪呢。这个图形是添加了`transform`的,实际所在区域已经不是包围盒形成的区域了。删掉其它元素,只保留矩形,并且不添加`transform`相关属性。再点击矩形区域,发现打印出了事件信息,证明确实如此。 791 | 792 | **成功一小步啦!** 793 | 794 | 这是第一个问题。 795 | 796 | 第二个问题是描边和填充。在之前的代码中,为了方便,对所有图形都进行了填充和描边。而实际使用时则不太会是这样。而描边和填充与否,以及描边时的线宽,都会影响包含的判断。 797 | 798 | 让我们先解决第二个问题。 799 | #### 合并父元素的选项 800 | 801 | 遍历元素及父元素的`style`即可得到是否有描边或填充,但有时又需要对某个子元素单独设置无填充或无描边,为此引入`none`属性作补充。 802 | ```typescript 803 | class XElement { 804 | hasStroke () { 805 | if (this.style.stroke === 'none') { 806 | return false 807 | } 808 | if (this.style.stroke && this.style.lineWidth > 0) { 809 | return true 810 | } 811 | if (!this.style.stroke) { 812 | let parent = this.parent 813 | if (parent && parent.hasStroke()) { 814 | return true 815 | } 816 | } 817 | 818 | return false 819 | } 820 | hasFill () { 821 | if (this.style.fill === 'none') { 822 | return false 823 | } 824 | if (this.style.fill) { 825 | return true 826 | } 827 | if (!this.style.fill) { 828 | let parent = this.parent 829 | if (parent && parent.hasFill()) { 830 | return true 831 | } 832 | } 833 | 834 | return false 835 | } 836 | /** 837 | * 绘制之后进行还原 838 | */ 839 | afterRender (ctx: CanvasRenderingContext2D) { 840 | if (this.hasStroke()) { 841 | ctx.stroke() 842 | } 843 | if (this.hasFill()) { 844 | ctx.fill() 845 | } 846 | ctx.restore() 847 | this.handleParentAfterRender(ctx) 848 | } 849 | } 850 | ``` 851 | 这个过程我发现了一个问题,那就是虽然子元素在绘制出来后能拥有父元素的样式,但是自身需要获取到这些样式时,则获取到的数据和表现的不一致。也许应该修改一下。 852 | ```typescript 853 | class XElement { 854 | updateOptions (opt?: XElementOptions) { 855 | if (!opt) { 856 | this.updateOptionsFromParent() 857 | opt = this.options 858 | } 859 | ['origin', 'scale', 'position', 'rotation'].forEach(key => { 860 | if (opt[key] !== undefined) { 861 | this[key] = opt[key] 862 | // 之前顺序放错了,这里修正一下 863 | this.selfNeedTransform = true 864 | } 865 | }); 866 | // ... 867 | } 868 | /** 869 | * 合并自身和父元素的配置 870 | */ 871 | getOptions () { 872 | let opt = clone(this.options); 873 | // 不包括变换相关的属性 874 | ['position','scale', 'origin', 'rotation'].forEach(key => { 875 | delete opt[key] 876 | }) 877 | if (!this.parent) { 878 | return opt 879 | } 880 | 881 | return merge(opt, this.parent.getOptions()) 882 | } 883 | /** 884 | * 先从父元素更新配置 885 | */ 886 | updateOptionsFromParent () { 887 | if (!this.parent) { 888 | return 889 | } 890 | this.updateOptions(this.parent.getOptions()) 891 | } 892 | setParent () { 893 | //... 894 | this.updateOptions() 895 | } 896 | } 897 | ``` 898 | 把`Group`的`handleParentBeforeRender`改为只设置变换。 899 | 再修改一下描边和填充的判定。 900 | ```typescript 901 | class XElement { 902 | hasStroke () { 903 | if (this.style.stroke === 'none') { 904 | return false 905 | } 906 | // 认为图片没有描边 907 | if (this.style.stroke && this.style.lineWidth > 0 && this.name !== 'image') { 908 | return true 909 | } 910 | 911 | return false 912 | } 913 | hasFill () { 914 | if (this.style.fill === 'none') { 915 | return false 916 | } 917 | if (this.style.fill) { 918 | return true 919 | } 920 | 921 | return false 922 | } 923 | } 924 | ``` 925 | 现在ok了。 926 | 接着是描边宽度的问题,为了保持`Path`的独立性,我们将描边处理移到`XElement`中。 927 | ```typescript 928 | class XElement { 929 | /** 930 | * 是否包含某个点 931 | */ 932 | contain (x: number, y: number) { 933 | // 触发事件时可能还没有调用refresh 934 | if (!this.path._ctx) { 935 | return 936 | } 937 | return this.getBoundingRect().contain(x, y) 938 | } 939 | getBoundingRect () { 940 | // 第一次和需要更新时才重新获取包围盒 941 | // 为此需要在更新时将_rect置为null 942 | // 尽管不是所有属性更新都会引起包围盒变化,暂时先不管 943 | if (!this._rect) { 944 | this._rect = this.path.getBoundingRect() 945 | let rect = this._rect 946 | let lineWidth = this.style.lineWidth 947 | if (this.hasStroke()) { 948 | // 因为描边是两边都描 949 | rect.x -= lineWidth / 2 950 | rect.y -= lineWidth / 2 951 | rect.width += lineWidth 952 | rect.height += lineWidth 953 | } 954 | } 955 | return this._rect 956 | } 957 | } 958 | ``` 959 | 为矩形添加5的线宽,发现仍然能够捕捉到,ok! 960 | 961 | 然后再来解决第一个问题。 962 | #### 坐标变换 963 | 解决这个问题,可以变换点的坐标。同样在之后的精确检测中也会需要变换点的坐标。 964 | ![](./images/v6/transform.png) 965 | 966 | 如图,将点进行元素的逆变换即可。如果存在父元素,那么先进行父元素上的逆变换。 967 | > 发现了一个`bug`,添加`Layer`的功能时导致设置`group`的值现在不能触发刷新了。修复一下。 968 | ```typescript 969 | class Group { 970 | dirty () { 971 | let children = this.stage.xelements 972 | for (let i = 0; i < children.length; i += 1) { 973 | children[i].dirty() 974 | } 975 | } 976 | } 977 | ``` 978 | 以`position: [20, 20], scale: [2, 2], origin: [300, 500], rotation: 0.1`为例,对点黑色标记的交叉点进行逆变换。如图: 979 | ![](./images/v6/rect-point.png) 980 | 981 | 逆变换之后交叉点应该在原始矩形,也就是黑色包围盒的左下角。 982 | 983 | 从图中很容易看出它的逆变换过程应该为: 984 | 1. 得出它绕中心点旋转对应角度后的坐标。 985 | 2. 用缩放后的平移距离来逆平移。 986 | 3. 绕中心点缩放回原来的大小。 987 | 988 | > **修复之前setTransform**的错误,缩放旋转后重置偏移时应该算上缩放。同时应该说我们并不希望偏移也被放大,也做对应修改。 989 | ```typescript 990 | class XElement { 991 | setTransform (ctx: CanvasRenderingContext2D) { 992 | // ... 993 | ctx.translate(-this.origin[0] / this.scale[0], -this.origin[1] / this.scale[1]) 994 | // 平移 995 | // 平移 996 | ctx.translate(this.position[0] / this.scale[0], this.position[1] / this.scale[1]) 997 | } 998 | } 999 | ``` 1000 | 根据这个过程可以写出如下函数: 1001 | ```typescript 1002 | function getTransformCord(x, y, transform: Transform) { 1003 | // 所有距离都要乘以缩放系数 1004 | let scaleX = transform.scale[0] 1005 | let scaleY = transform.scale[1] 1006 | 1007 | 1008 | // 得出它绕中心点旋转相反角度后的坐标 1009 | // 证明过程参考前文 1010 | let sinRotation = Math.sin(-transform.rotation) 1011 | let cosRotation = Math.cos(-transform.rotation) 1012 | let x2ox = x - transform.origin[0] 1013 | let y2oy = y - transform.origin[1] 1014 | x = x2ox * cosRotation - y2oy * sinRotation + transform.origin[0] 1015 | y = x2ox * sinRotation + y2oy * cosRotation + transform.origin[1] 1016 | 1017 | // 平移 1018 | x -= (transform.position[0] + transform.origin[0]) 1019 | y -= (transform.position[1] + transform.origin[1]) 1020 | // 绕中心点缩放 1021 | x = (x + transform.origin[0]) / scaleX 1022 | y = (y + transform.origin[1]) / scaleY 1023 | 1024 | return [x, y] 1025 | } 1026 | ``` 1027 | 接着应用它即可 1028 | ```typescript 1029 | class XElement { 1030 | /** 1031 | * 将坐标重置为本地坐标 1032 | */ 1033 | getLocalCord (x: number, y: number) { 1034 | // 计算的时候取相对定位的组,而不是父元素 1035 | if (this.relativeGroup) { 1036 | let inParentCord = this.relativeGroup.getLocalCord(x, y) 1037 | } 1038 | if (this.selfNeedTransform) { 1039 | let transformCord = getTransformCord(x, y, { 1040 | scale: this.scale, 1041 | origin: this.origin, 1042 | position: this.position, 1043 | rotation: this.rotation 1044 | }) 1045 | x = transformCord[0] 1046 | y = transformCord[1] 1047 | } 1048 | 1049 | 1050 | return [x, y] 1051 | 1052 | } 1053 | /** 1054 | * 是否包含某个点 1055 | */ 1056 | contain (x: number, y: number) { 1057 | let local = this.getLocalCord(x, y) 1058 | x = local[0] 1059 | y = local[1] 1060 | 1061 | return this.getBoundingRect().contain(x, y) 1062 | } 1063 | } 1064 | ``` 1065 | 再去尝试可以发现变换后的元素仍然能正确检测包围盒是否包含。 1066 | ### 其它绘制方法的包围盒 1067 | 下面让我们补充其它方法的包围盒。考虑都有些方法的包围盒求取比较复杂,为此创建`bbox.ts`导出这些求取方法。 1068 | #### arc的包围盒 1069 | 求圆弧的包围盒,虽然要考虑的情况比较多,但是容易看出在0到90度这个区间内,包围盒即是起始点和终点为对角线构成的矩形,90度到180度内也是如此。而前面说过对路径可以分别求包围盒然后求并集。同样一段圆弧我们也可以拆分成多段圆弧分别求包围盒,然后求并集。如此一来,事情就变得简单了。 1070 | ```typescript 1071 | import BoundingRect from './BoundingRect' 1072 | 1073 | /** 1074 | * 从圆弧绘制中求取包围盒 1075 | * 写入min max中 1076 | */ 1077 | export function fromArc ( 1078 | cx: number, 1079 | cy: number, 1080 | r: number, 1081 | startAngle: number, 1082 | endAngle: number, 1083 | anticlockwise: boolean | number, 1084 | min: any[], 1085 | max: any[] 1086 | ) { 1087 | // 思路是将圆弧分为好几段子圆弧,分别求包围盒 1088 | // 首先如果这是一个圆,那么就不用进行后续计算,但是需要考虑的是,接近一个圆也可以认为是一个圆 1089 | let delta = Math.abs(endAngle - startAngle) 1090 | let PI2 = Math.PI * 2 1091 | if (delta >= PI2 || Math.abs(delta - PI2) < 1e-3) { 1092 | min[0] = cx - r 1093 | min[1] = cy - r 1094 | max[0] = cx + r 1095 | max[1] = cy + r 1096 | 1097 | return 1098 | } 1099 | // 求出起点和终点的坐标 1100 | let start = [Math.cos(startAngle) * r + cx, Math.sin(startAngle) * r + cy] 1101 | let end = [Math.cos(endAngle) * r + cx, Math.sin(endAngle) * r + cy] 1102 | // 得出初始包围盒 1103 | fromLine(start, end, min, max) 1104 | let rect = new BoundingRect(min[0], min[1], max[0] - min[0], max[1] - min[1]) 1105 | // 让起始角度和结束角度都大于0 1106 | startAngle = startAngle % PI2 1107 | if (startAngle < 0) { 1108 | startAngle += PI2 1109 | } 1110 | endAngle = endAngle % PI2 1111 | if (endAngle < 0) [ 1112 | endAngle += PI2 1113 | ] 1114 | // 如果是逆时针,将二者对调 1115 | if (anticlockwise) { 1116 | let temp = endAngle 1117 | endAngle = startAngle 1118 | startAngle = temp 1119 | } 1120 | // 求出起始点和终点之间所有最值,从0开始每次循环加90度 1121 | let point = [] 1122 | for (let angle = 0; angle < endAngle; angle += (Math.PI / 2)) { 1123 | if (angle > startAngle) { 1124 | point[0] = Math.cos(angle) * r + cx 1125 | point[1] = Math.sin(angle) * r + cy 1126 | 1127 | fromLine(min, point, min, []) 1128 | fromLine(max, point, [], max) 1129 | 1130 | rect.union({ 1131 | x: min[0], 1132 | y: min[1], 1133 | width: max[0] - min[0], 1134 | height: max[1] - min[1] 1135 | }) 1136 | } 1137 | } 1138 | 1139 | // 重新写入 1140 | min[0] = rect.x 1141 | min[1] = rect.y 1142 | max[0] = rect.x + rect.width 1143 | max[1] = rect.y + rect.height 1144 | } 1145 | /** 1146 | * 从两点之间求出包围盒,并写入min max中 1147 | */ 1148 | export function fromLine (point1: number[], point2: number[], min: any[], max: any[]) { 1149 | min[0] = Math.min(point1[0], point2[0]) 1150 | min[1] = Math.min(point1[1], point2[1]) 1151 | max[0] = Math.max(point1[0], point2[0]) 1152 | max[1] = Math.max(point1[1], point2[1]) 1153 | } 1154 | // Path.ts 1155 | class Path { 1156 | getBoundingRect() { 1157 | //... 1158 | case PathType.arc: 1159 | fromArc( 1160 | params[0], 1161 | params[1], 1162 | params[2], 1163 | params[3], 1164 | params[4], 1165 | params[5], 1166 | min, 1167 | max 1168 | ) 1169 | break 1170 | } 1171 | } 1172 | ``` 1173 | 创建`Arc`类元素来验证是否正确。 1174 | ```typescript 1175 | import XElement, { XElementShape, XElementOptions } from './XElement' 1176 | 1177 | interface ArcShape extends XElementShape { 1178 | /** 1179 | * 圆心x坐标 1180 | */ 1181 | cx: number 1182 | /** 1183 | * 圆心y坐标 1184 | */ 1185 | cy: number 1186 | /** 1187 | * 半径 1188 | */ 1189 | r: number 1190 | /** 1191 | * 开始角度 1192 | */ 1193 | startAngle: number 1194 | /** 1195 | * 结束角度 1196 | */ 1197 | endAngle: number 1198 | /** 1199 | * 是否顺时针,默认为true 1200 | */ 1201 | clockwise?: boolean 1202 | } 1203 | interface ArcOptions extends XElementOptions { 1204 | shape?: ArcShape 1205 | } 1206 | 1207 | class Arc extends XElement { 1208 | name = 'arc' 1209 | shape: ArcShape = { 1210 | cx: 0, 1211 | cy: 0, 1212 | r: 100, 1213 | startAngle: 0, 1214 | endAngle: 0, 1215 | clockwise: true 1216 | } 1217 | constructor (opt: ArcOptions = {}) { 1218 | super(opt) 1219 | this.updateOptions() 1220 | } 1221 | render (ctx: CanvasRenderingContext2D) { 1222 | let shape = this.shape 1223 | ctx.arc(shape.cx, shape.cy, shape.r, shape.startAngle, shape.endAngle, !shape.clockwise) 1224 | } 1225 | } 1226 | 1227 | export default Arc 1228 | 1229 | ``` 1230 | 经过测试,发现一个问题,在使用`arc`之前如果有其它的命令,如`moveTo`,最后求出的包围盒不符合需求。如图: 1231 | 1232 | ![](images/v6/arc-boudingrect.png) 1233 | 1234 | 说明还需要考虑到绘制起点——即画笔所在点,它可以由`moveTo`等命令来改变,以及`beginPath`后所移动的第一个点,即路径开始点。为此创建以下三个变量,并在命令中更新。 1235 | ```typescript 1236 | class Path { 1237 | getBoundingRect () { 1238 | // ... 1239 | // 当前元素路径起始点,用于closePath,第一个命令和moveTo会改变它 1240 | let start = [0, 0] 1241 | // 上一个命令的终点 1242 | let prePathFinal = [0, 0] 1243 | // 当前命令的起点,用来和prePathFinal一起计算包围盒 1244 | let pathStartPoint = [0, 0] 1245 | // 遍历绘制过程,分别求取包围盒 1246 | for (let i = 0; i < data.length; i += 1) { 1247 | // ... 1248 | pathStartPoint[0] = params[0] 1249 | pathStartPoint[1] = params[1] 1250 | // 对于第一个命令不是moveto的情况设置绘制起点和当前元素路径起始点 1251 | // 对于arc会在后续处理 1252 | if (i === 0) { 1253 | start[0] = pathStartPoint[0] 1254 | start[1] = pathStartPoint[1] 1255 | prePathFinal[0] = pathStartPoint[0] // x 1256 | prePathFinal[1] = pathStartPoint[1] 1257 | } 1258 | switch () { 1259 | case PathType.arc: 1260 | // ... 1261 | pathStartPoint[0] = Math.cos(params[3]) * params[2] + params[0] 1262 | pathStartPoint[1] = Math.sin(params[3]) * params[2] + params[1] 1263 | prePathFinal[0] = Math.cos(params[4]) * params[2] + params[0] 1264 | prePathFinal[1] = Math.sin(params[4]) * params[2] + params[1] 1265 | if (i === 0) { 1266 | start[0] = pathStartPoint[0] 1267 | start[1] = pathStartPoint[1] 1268 | } 1269 | break 1270 | case PathType.bezierCurveTo: 1271 | prePathFinal[0] = params[4] 1272 | prePathFinal[1] = params[5] 1273 | break 1274 | case PathType.lineTo: 1275 | prePathFinal[0] = params[0] 1276 | prePathFinal[1] = params[1] 1277 | break 1278 | case PathType.moveTo: 1279 | start[0] = params[0] // x 1280 | start[1] = params[1] // y 1281 | prePathFinal[0] = params[0] // x 1282 | prePathFinal[1] = params[1] // y 1283 | break 1284 | case PathType.quadraticCurveTo: 1285 | prePathFinal[0] = params[2] 1286 | prePathFinal[1] = params[3] 1287 | break 1288 | case PathType.rect: 1289 | prePathFinal[0] = params[0] 1290 | prePathFinal[1] = params[1] 1291 | break 1292 | case PathType.closePath: 1293 | prePathFinal[0] = start[0] 1294 | prePathFinal[1] = start[1] 1295 | default: 1296 | break 1297 | } 1298 | } 1299 | } 1300 | ``` 1301 | 它们有什么用呢?可以计算当前路径和上一段路径终点的包围盒,也可以计算最后`closePath`时和起始点的包围盒(即使不调用`closePath`,也要这样计算)。为此改变之前的代码。 1302 | ```typescript 1303 | class Path { 1304 | getBoundingRect () { 1305 | // ... 1306 | function union () { 1307 | if (rect) { 1308 | rect.union({ 1309 | x: min[0], 1310 | y: min[1], 1311 | width: max[0] - min[0], 1312 | height: max[1] - min[1] 1313 | }) 1314 | } else { 1315 | rect = new BoundingRect( 1316 | min[0], 1317 | min[1], 1318 | max[0] - min[0], 1319 | max[1] - min[1] 1320 | ) 1321 | } 1322 | } 1323 | for (let i = 0; i < data.length; i += 1) { 1324 | // ... 1325 | switch (pathData.type) { 1326 | case PathType.arc: 1327 | pathStartPoint[0] = Math.cos(params[3]) * params[2] + params[0] 1328 | pathStartPoint[1] = Math.sin(params[3]) * params[2] + params[1] 1329 | // 从上一个命令的绘制终点到当前路径的起点构建包围盒 1330 | fromLine(prePathFinal, pathStartPoint, min, max) 1331 | union() 1332 | fromArc( 1333 | params[0], 1334 | params[1], 1335 | params[2], 1336 | params[3], 1337 | params[4], 1338 | params[5], 1339 | min, 1340 | max 1341 | ) 1342 | prePathFinal[0] = Math.cos(params[4]) * params[2] + params[0] 1343 | prePathFinal[1] = Math.sin(params[4]) * params[2] + params[1] 1344 | //... 1345 | } 1346 | // 和当前路径的包围盒相交 1347 | union() 1348 | // 每个命令结束后都要用当前路径终点和当前元素所有路径的起始点来构建包围盒 1349 | fromLine(prePathFinal, start, min, max) 1350 | union() 1351 | } 1352 | } 1353 | ``` 1354 | 注意到有这么一句话**每个命令结束后都要用当前路径终点和当前元素所有路径的起始点来构建包围盒**,这一段代码每一个命令结束都会执行一次,而不是所有命令结束后只执行一次,这是因为,考虑如下代码和结果: 1355 | ```typescript 1356 | ctx.moveTo(10, 10) 1357 | ctx.arc(shape.cx, shape.cy, shape.r * 2, shape.startAngle, shape.endAngle, !shape.clockwise) 1358 | ctx.moveTo(100, 100) 1359 | ctx.arc(shape.cx, shape.cy, shape.r, shape.startAngle, shape.endAngle, !shape.clockwise) 1360 | ``` 1361 | ![](images/v6/arc-fill-rule.png) 1362 | 1363 | 可以看到第一段弧上和(10, 10)形成闭合的,而不是和点(100, 100)形成闭合。 1364 | 1365 | 再次验证它的包围盒,ok! 1366 | ### lineto的包围盒 1367 | line则比较简单。 1368 | ```typescript 1369 | class Path { 1370 | getBoundingRect () { 1371 | switch (pathData.type) { 1372 | case PathType.lineTo: 1373 | fromLine(start, params, min, max) 1374 | start[0] = params[0] 1375 | start[1] = params[1] 1376 | } 1377 | } 1378 | } 1379 | } 1380 | ``` 1381 | 创建`Line`类元素来验证。 1382 | ```typescript 1383 | import XElement, { XElementShape, XElementOptions } from './XElement' 1384 | 1385 | interface LineShape extends XElementShape { 1386 | /** 1387 | * 起点横坐标 1388 | */ 1389 | x1: number 1390 | /** 1391 | * 起点纵坐标 1392 | */ 1393 | y1: number 1394 | /** 1395 | * 终点横坐标 1396 | */ 1397 | x2: number 1398 | /** 1399 | * 终点纵坐标 1400 | */ 1401 | y2: number 1402 | } 1403 | interface LineOptions extends XElementOptions { 1404 | shape?: LineShape 1405 | } 1406 | 1407 | class Line extends XElement { 1408 | name ='line' 1409 | shape: LineShape = { 1410 | x1: 0, 1411 | y1: 0, 1412 | x2: 0, 1413 | y2: 0 1414 | } 1415 | constructor (opt: LineOptions = {}) { 1416 | super(opt) 1417 | this.updateOptions() 1418 | } 1419 | render (ctx: CanvasRenderingContext2D) { 1420 | let shape = this.shape 1421 | ctx.moveTo(shape.x1, shape.y1) 1422 | ctx.lineTo(shape.x2, shape.y2) 1423 | } 1424 | } 1425 | 1426 | export default Line 1427 | 1428 | ``` 1429 | 测试没有发现问题。 1430 | ### percent 进度动画 1431 | 此外有一个常见的需求是,绘制图表时往往需要一些进度动画,如柱状图从零到应有的高度、饼图从零到应有的弧度,这些图形的动画指定对应属性即可。而对于线性元素,如线段和贝塞尔曲线,则无法这么做——除了指定属性,还需要复杂的计算。所以这一步应该由`xrender`来完成,为此添加`percent`属性,绘制时根据进度计算出要绘制的点。如此一来动画就变得简单了。 1432 | ```typescript 1433 | class Line { 1434 | render () { 1435 | //... 1436 | let x2 = shape.x2 1437 | let y2 = shape.y2 1438 | if (shape.percent < 1) { 1439 | x2 = shape.x1 + (x2 - shape.x1) * shape.percent 1440 | x2 = shape.y1 + (y2 - shape.y1) * shape.percent 1441 | } 1442 | } 1443 | } 1444 | ``` 1445 | 回到我们的包围盒计算。 1446 | ### 贝塞尔曲线 1447 | 关于贝塞尔曲线,相信大家都有所了解,只是了解的程度可能有所不同,以二次贝塞尔曲线为例,简单说一下它的计算公式和来源。 1448 | #### 公式和来源 1449 | 二次贝塞尔曲线由三个点P0,P1,P2来确定,这些点也被称作控制点。曲线的方程为(横纵坐标计算方式相同): 1450 | 1451 | ![](math/v6/公式_1.png) 1452 | 1453 | 这个公式怎么来的呢?这需要知道贝塞尔曲线究竟是如何绘制的。对于二次贝塞尔曲线来说,绘制过程如下。 1454 | 1. 选定`0 - 1`之间任意一个值`t`,以`0.5`为例。 1455 | 2. 连线`P0`和`P1`,找出线段的中点`Q0`(因为`t = 0.5`,下同)。 1456 | 3. 连线`P1`和`P2`,找出线段的中点`Q1`。 1457 | 4. 连线`Q0`和`Q1`,线段的中点`B`即是`t = 0.5`时曲线上的点。 1458 | 1459 | 让`t`从0到1就可计算出整条曲线。知道线段端点的坐标和目标点所占的百分比,求目标点坐标这很简单,就不写出来了。三次贝塞尔曲线同理。 1460 | 1461 | ![](./images/v6/curve.png)(图片来源:[hujiulong](https://www.imooc.com/article/22628)) 1462 | 1463 | #### 进度动画 1464 | 虽然这一块不属于包围盒的内容,但是都说到这儿了。 1465 | 1466 | 前面说过为了让这些线性元素能够动画,需要我们计算出指定百分比时线上点的坐标,贝塞尔曲线上的点虽然能够计算出来,但是并不能直接用于绘制。因为`canvas`绘制贝塞尔曲线时只能指定起点终点和控制点。观察上图可以发现,`P0`到`B`这一段曲线,同样是一段贝塞尔曲线,只不过它以`P0`为起点,`B`为终点,`Q0`为控制点,而这些点都是可以计算的。那么贝塞尔曲线的进度动画也有了解决之道了。 1467 | 1468 | 考虑到后面还有很多这方面的函数,在`util`文件夹下创建`curve.ts`。第一个函数如下: 1469 | ```typescript 1470 | /** 1471 | * 细分二次贝塞尔曲线,对于横纵坐标分别传入计算 1472 | * @param p0 起点坐标 1473 | * @param p1 控制点坐标 1474 | * @param p2 终点坐标 1475 | * @param t 进度 1476 | * @param out 保存结果的数组 1477 | */ 1478 | export function quadraticSubdivide(p0: number, p1: number, p2: number, t: number, out: any[] = []) { 1479 | let q0 = (p1 - p0) * t + p0 1480 | let q1 = (p2 - p1) * t + p1 1481 | let b = (q1 - q0) * t + q0 1482 | 1483 | out[0] = p0 1484 | out[1] = q0 1485 | out[2] = b 1486 | } 1487 | ``` 1488 | #### 包围盒 1489 | 求贝塞尔曲线的包围盒,即是求极限值,准确地说,是求纵坐标的极限值。对公式求导,很容易得出极限值出现时`t`的值(t从0到1,超出此范围的值则舍弃),然后求出极限值。 1490 | ##### 二次 1491 | ![](math/v6/公式_2.png) 1492 | ##### 三次 1493 | ![](math/v6/公式_3.png) 1494 | 1495 | **以下部分代码copy自zrender相关部分** 1496 | ```typescript 1497 | // curve.ts 1498 | /** 1499 | * 细分三次贝塞尔曲线,参数同上 1500 | */ 1501 | export function cubicSubdivide(p0: number, p1: number, p2: number, p3: number, t: number, out: any[]) { 1502 | let q0 = (p1 - p0) * t + p0 1503 | let q1 = (p2 - p1) * t + p1 1504 | let q2 = (p3 - p2) * t + p2 1505 | 1506 | let r0 = (q1 - q0) * t + q0 1507 | let r1 = (q2 - q1) * t + q1 1508 | 1509 | let b = (r1 - r0) * t + r0 1510 | 1511 | out[0] = p0 1512 | out[1] = q0 1513 | out[2] = r0 1514 | out[3] = b 1515 | 1516 | } 1517 | let xDim = [] 1518 | let yDim = [] 1519 | /** 1520 | * 从三阶贝塞尔曲线(p0, p1, p2, p3)中计算出最小包围盒,写入`min`和`max`中 1521 | */ 1522 | export function fromCubic( 1523 | x0: number, y0: number, x1: number, y1: number, x2: number, y2: number, x3: number, y3: number, min: any[], max: any[] 1524 | ) { 1525 | let cubicExtrema = curve.cubicExtrema 1526 | let cubicAt = curve.cubicAt 1527 | let i 1528 | let n = cubicExtrema(x0, x1, x2, x3, xDim) 1529 | min[0] = Infinity 1530 | min[1] = Infinity 1531 | max[0] = -Infinity 1532 | max[1] = -Infinity 1533 | 1534 | for (i = 0; i < n; i++) { 1535 | let x = cubicAt(x0, x1, x2, x3, xDim[i]) 1536 | min[0] = Math.min(x, min[0]) 1537 | max[0] = Math.max(x, max[0]) 1538 | } 1539 | n = cubicExtrema(y0, y1, y2, y3, yDim) 1540 | for (i = 0; i < n; i++) { 1541 | let y = cubicAt(y0, y1, y2, y3, yDim[i]) 1542 | min[1] = Math.min(y, min[1]) 1543 | max[1] = Math.max(y, max[1]) 1544 | } 1545 | 1546 | min[0] = Math.min(x0, min[0]) 1547 | max[0] = Math.max(x0, max[0]) 1548 | min[0] = Math.min(x3, min[0]) 1549 | max[0] = Math.max(x3, max[0]) 1550 | 1551 | min[1] = Math.min(y0, min[1]) 1552 | max[1] = Math.max(y0, max[1]) 1553 | min[1] = Math.min(y3, min[1]) 1554 | max[1] = Math.max(y3, max[1]) 1555 | } 1556 | 1557 | /** 1558 | * 从二阶贝塞尔曲线(p0, p1, p2)中计算出最小包围盒,写入`min`和`max`中 1559 | */ 1560 | export function fromQuadratic(x0: number, y0: number, x1: number, y1: number, x2: number, y2: number, min: any[], max: any[]) { 1561 | let quadraticExtremum = curve.quadraticExtremum 1562 | let quadraticAt = curve.quadraticAt 1563 | let tx = 1564 | Math.max( 1565 | Math.min(quadraticExtremum(x0, x1, x2), 1), 0 1566 | ) 1567 | let ty = 1568 | Math.max( 1569 | Math.min(quadraticExtremum(y0, y1, y2), 1), 0 1570 | ) 1571 | 1572 | let x = quadraticAt(x0, x1, x2, tx) 1573 | let y = quadraticAt(y0, y1, y2, ty) 1574 | 1575 | min[0] = Math.min(x0, x2, x) 1576 | min[1] = Math.min(y0, y2, y) 1577 | max[0] = Math.max(x0, x2, x) 1578 | max[1] = Math.max(y0, y2, y) 1579 | } 1580 | // curve.ts 1581 | let EPSILON = 1e-8 1582 | 1583 | function isAroundZero(val) { 1584 | return val > -EPSILON && val < EPSILON 1585 | } 1586 | function isNotAroundZero(val) { 1587 | return val > EPSILON || val < -EPSILON 1588 | } 1589 | /** 1590 | * 计算三次贝塞尔方程极限值的位置 1591 | */ 1592 | export function cubicExtrema(p0: number, p1: number, p2: number, p3: number, extrema: any[]) { 1593 | let b = 6 * p2 - 12 * p1 + 6 * p0 1594 | let a = 9 * p1 + 3 * p3 - 3 * p0 - 9 * p2 1595 | let c = 3 * p1 - 3 * p0 1596 | 1597 | let n = 0 1598 | // 这里遵循zrender的设计,考虑到计算精度和近似值的原因 1599 | if (isAroundZero(a)) { 1600 | if (isNotAroundZero(b)) { 1601 | let t1 = -c / b 1602 | if (t1 >= 0 && t1 <= 1) { 1603 | extrema[n++] = t1 1604 | } 1605 | } 1606 | } 1607 | else { 1608 | let disc = b * b - 4 * a * c 1609 | if (isAroundZero(disc)) { 1610 | extrema[0] = -b / (2 * a) 1611 | } 1612 | else if (disc > 0) { 1613 | let discSqrt = Math.sqrt(disc) 1614 | let t1 = (-b + discSqrt) / (2 * a) 1615 | let t2 = (-b - discSqrt) / (2 * a) 1616 | if (t1 >= 0 && t1 <= 1) { 1617 | extrema[n++] = t1 1618 | } 1619 | if (t2 >= 0 && t2 <= 1) { 1620 | extrema[n++] = t2 1621 | } 1622 | } 1623 | } 1624 | return n 1625 | } 1626 | /** 1627 | * 计算二次贝塞尔方程极限值 1628 | * @memberOf module:zrender/core/curve 1629 | * @param {number} p0 1630 | * @param {number} p1 1631 | * @param {number} p2 1632 | * @return {number} 1633 | */ 1634 | export function quadraticExtremum(p0: number, p1: number, p2: number) { 1635 | let divider = p0 + p2 - 2 * p1 1636 | if (divider === 0) { 1637 | return 0.5 1638 | } 1639 | else { 1640 | return (p0 - p1) / divider 1641 | } 1642 | } 1643 | /** 1644 | * 计算二次方贝塞尔值 1645 | */ 1646 | export function quadraticAt(p0: number, p1: number, p2: number, t: number) { 1647 | let onet = 1 - t 1648 | return onet * (onet * p0 + 2 * t * p1) + t * t * p2 1649 | } 1650 | /** 1651 | * 计算三次贝塞尔值 1652 | */ 1653 | export function cubicAt(p0: number, p1: number, p2: number, p3: number, t: number) { 1654 | let onet = 1 - t 1655 | return onet * onet * (onet * p0 + 3 * t * p1) 1656 | + t * t * (t * p3 + 3 * onet * p2) 1657 | } 1658 | ``` 1659 | 同样的,创建`BezierCurve`类元素来验证。 1660 | ```typescript 1661 | import XElement, { XElementShape, XElementOptions } from './XElement' 1662 | import { 1663 | quadraticSubdivide, 1664 | quadraticAt, 1665 | cubicAt, 1666 | cubicSubdivide 1667 | } from '../util/curve' 1668 | 1669 | interface BezierCurveShape extends XElementShape { 1670 | /** 1671 | * 起点横坐标 1672 | */ 1673 | x1: number 1674 | /** 1675 | * 起点纵坐标 1676 | */ 1677 | y1: number 1678 | /** 1679 | * 终点横坐标 1680 | */ 1681 | x2: number 1682 | /** 1683 | * 终点纵坐标 1684 | */ 1685 | y2: number 1686 | /** 1687 | * 控制点1横坐标 1688 | */ 1689 | cpx1: number 1690 | /** 1691 | * 控制点1纵坐标 1692 | */ 1693 | cpy1: number 1694 | /** 1695 | * 控制点2横坐标,三次曲线 1696 | */ 1697 | cpx2?: number 1698 | /** 1699 | * 控制点2纵坐标,三次曲线 1700 | */ 1701 | cpy2?: number 1702 | /** 1703 | * 进度 1704 | */ 1705 | percent?: number 1706 | } 1707 | interface BezierCurveOptions extends XElementOptions { 1708 | shape?: BezierCurveShape 1709 | } 1710 | 1711 | class BezierCurve extends XElement { 1712 | name = 'beziercurve' 1713 | shape: BezierCurveShape = { 1714 | x1: 0, 1715 | y1: 0, 1716 | x2: 0, 1717 | y2: 0, 1718 | cpx1: 0, 1719 | cpy1: 0, 1720 | percent: 1 1721 | } 1722 | constructor (opt: BezierCurveOptions = {}) { 1723 | super(opt) 1724 | this.updateOptions() 1725 | } 1726 | render (ctx: CanvasRenderingContext2D) { 1727 | let shape = this.shape 1728 | let x1 = shape.x1 1729 | let y1 = shape.y1 1730 | let x2 = shape.x2 1731 | let y2 = shape.y2 1732 | let cpx1 = shape.cpx1 1733 | let cpy1 = shape.cpy1 1734 | let cpx2 = shape.cpx2 1735 | let cpy2 = shape.cpy2 1736 | let percent = shape.percent 1737 | if (percent === 0) { 1738 | return 1739 | } 1740 | let out = [] 1741 | ctx.moveTo(x1, y1) 1742 | if (cpx2 === undefined || cpy2 == undefined) { 1743 | if (percent < 1) { 1744 | quadraticSubdivide( 1745 | x1, cpx1, x2, percent, out 1746 | ) 1747 | cpx1 = out[1] 1748 | x2 = out[2] 1749 | quadraticSubdivide( 1750 | y1, cpy1, y2, percent, out 1751 | ) 1752 | cpy1 = out[1] 1753 | y2 = out[2] 1754 | } 1755 | ctx.quadraticCurveTo(cpx1, cpy1,x2, y2) 1756 | } 1757 | else { 1758 | if (percent < 1) { 1759 | cubicSubdivide(x1, cpx1, cpx2, x2, percent, out) 1760 | cpx1 = out[1] 1761 | cpx2 = out[2] 1762 | x2 = out[3] 1763 | cubicSubdivide(y1, cpy1, cpy2, y2, percent, out) 1764 | cpy1 = out[1] 1765 | cpy2 = out[2] 1766 | y2 = out[3] 1767 | } 1768 | ctx.bezierCurveTo(cpx1, cpy1, cpx2, cpy2, x2, y2) 1769 | } 1770 | } 1771 | } 1772 | 1773 | export default BezierCurve 1774 | 1775 | ``` 1776 | 测试之后没有发现问题。 1777 | 1778 | ### 其它 1779 | 其它的包围盒计算就请自行探索了,写的我都头大了。 1780 | 1781 | 1782 | 如果你一口气看到这里,那么你应该休息会了。 1783 | 1784 | 下半篇,[精确检测和其应用](./Version6.1.md)。 1785 | -------------------------------------------------------------------------------- /Version7.md: -------------------------------------------------------------------------------- 1 | # 从零打造Echarts —— V7 文本和完结 2 | 本文开始v7版本。 3 | ## 回顾v6 4 | 在v6版本中我们添加了事件处理的功能。 5 | ## Text 6 | 一个图表应用,文本显然是必不可少的内容,而`canvas`中的文本,并没有想象中那么简单。本版本中将完成大功能的最后一项——文本,,完成之后`XRender`即可暂时收工了。 7 | 8 | 根据之前的经验,很容易创建`Text`元素。 9 | ```typescript 10 | import XElement, { XElementShape, XElementOptions } from './XElement' 11 | 12 | interface TextShape extends XElementShape { 13 | x?: number 14 | y?: number 15 | /** 16 | * 要绘制的文本 17 | */ 18 | text?: string 19 | } 20 | interface TextOptions extends XElementOptions { 21 | shape?: TextShape 22 | } 23 | 24 | class Text extends XElement { 25 | name = 'text' 26 | shape: TextShape = { 27 | text: '', 28 | x: 0, 29 | y: 0 30 | } 31 | constructor (opt: TextOptions = {}) { 32 | super(opt) 33 | this.updateOptions() 34 | } 35 | render (ctx: CanvasRenderingContext2D) { 36 | let shape = this.shape 37 | if (this.hasFill()) { 38 | ctx.fillText(shape.text, shape.x, shape.y) 39 | } 40 | if (this.hasStroke()) { 41 | ctx.strokeText(shape.text, shape.x, shape.y) 42 | } 43 | } 44 | } 45 | 46 | export default Text 47 | // App.vue 48 | let text = new xrender.Text({ 49 | shape: { 50 | text: '这是一个文字', 51 | x: 0, 52 | y: 0 53 | }, 54 | style: { 55 | lineWidth: 5, 56 | fill: '#0f0', 57 | font: '24px serif' 58 | } 59 | }) 60 | xr.add(text) 61 | ``` 62 | ![](images/v7/text-baseline.png) 63 | 64 | 应用之后却发现画布上什么都没有——哦不对,仔细观察可以发现左上角有一撮阴影。这是因为`canvas`绘制文本时会依据坐标和基线来绘制的。也就是`textBaseline`和`textAlign`。除了这两个样式,文本专属的样式还有`font`,为了使用方便,将其拆分为`fontSize`和`fontFamily`。修改`bindStyle`和添加默认`style`。 65 | ```typescript 66 | // XElement.ts 67 | function bindStyle () { 68 | //... 69 | let font = `${style.fontSize}px ${style.fontFamily}` 70 | ctx.font = font; 71 | ['lineWidth', 'shadowBlur', 'shadowColor', 'shadowOffsetX', 'shadowOffsetY', 'textBaseline', 'textAlign'].forEach(prop => { 72 | if (style[prop] !== ctx[prop]) { 73 | ctx[prop] = style[prop] 74 | } 75 | }) 76 | } 77 | class XElement { 78 | style: XElementStyle = { 79 | // ... 80 | fontSize: 12, 81 | fontFamily: 'serif', 82 | textAlign: 'left', 83 | textBaseline: 'top' 84 | } 85 | } 86 | ``` 87 | 再看看结果: 88 | ![](images/v7/text-baselint1.png) 89 | 90 | ### 超出隐藏、换行和超出显示省略 91 | 标题所示的三个功能都是很常见的功能,然而`canvas`对文本排版的支持非常弱,是无法实现自动换行的。但是用手动来检测的话也很简单,计算文本宽度,找出要换行时的位置,对文本分批绘制即可。而省略与此相同。 92 | 93 | 超出隐藏则无法这么简单地实现。为了实现超出隐藏,我们需要引入新的概念——`clip`,裁剪。要想实现我们的目的,只需要在绘制之前,绘制一遍裁剪路径(文本超出隐藏需要的裁剪路径为矩形),然后调用`ctx.clip`(不熟悉`api`的请自行查阅)即可。而因为之前的苦工,我们可以将元素用于裁剪路径。 94 | 95 | 为`XElement`添加如下参数。 96 | ```typescript 97 | class XElement { 98 | /** 99 | * 用于裁剪的元素,只能通过`setClip`设置 100 | */ 101 | clip: XElement 102 | /** 103 | * 是否被用于裁剪,如果是的话,不会进行描边和填充 104 | */ 105 | isClip: boolean 106 | /** 107 | * 绘制之前进行样式的处理 108 | */ 109 | beforeRender (ctx: CanvasRenderingContext2D) { 110 | ctx.save() 111 | // 需要注意的是,裁剪路径有自己的`transform`体系,为了让裁剪路径和元素本身有相同的相对变换,需要在`setClip`中设置parent 112 | this.setCtxClip(ctx) 113 | this.handleParentBeforeRender(ctx) 114 | ctx.save() 115 | bindStyle(ctx, this.style) 116 | this.setTransform(ctx) 117 | ctx.beginPath() 118 | } 119 | /** 120 | * 绘制之后进行还原 121 | */ 122 | afterRender (ctx: CanvasRenderingContext2D) { 123 | if (this.hasFill() && !this.isClip) { 124 | ctx.fill() 125 | } 126 | if (this.hasStroke() && !this.isClip) { 127 | ctx.stroke() 128 | } 129 | // ... 130 | // 在最后,重置裁剪 131 | ctx.restore() 132 | } 133 | setParent (parent: Group) { 134 | //... 135 | // 更新裁剪路径的父元素 136 | if (this.clip) { 137 | this.setClip(this.clip) 138 | } 139 | } 140 | /** 141 | * 设置裁剪路径 142 | */ 143 | setClip (xel: XElement) { 144 | this.clip = xel 145 | // 为了能应用变换 146 | if (this.parent) { 147 | // 但又不被`getAll`所获取 148 | xel.ignored = true 149 | xel.setParent(this.parent) 150 | xel.options.relativeGroup = this.relativeGroup 151 | // 否则会不断循环 152 | xel._xr = null 153 | } 154 | this.dirty() 155 | } 156 | /** 157 | * 移除裁剪路径 158 | */ 159 | removeClip () { 160 | this.clip.ignored = false 161 | this.clip = null 162 | this.dirty() 163 | } 164 | /** 165 | * 为上下文设定裁剪路径 166 | */ 167 | setCtxClip (ctx: CanvasRenderingContext2D) { 168 | if (this.clip) { 169 | this.clip.isClip = true 170 | this.clip.refresh(ctx) 171 | this.clip.isClip = false 172 | ctx.clip() 173 | } 174 | } 175 | updateOptions (opt?: XElementOptions) { 176 | //... 177 | ['zLevel', 'relativeGroup', 'zIndex', 'renderByFrame'].forEach(key => { 178 | if (opt[key] !== undefined) { 179 | this[key] = opt[key] 180 | // 设置`clip`的相对定位元素 181 | if (key === 'relativeGroup') { 182 | this.clip && this.clip.attr({ 183 | relativeGroup: opt[key] 184 | }) 185 | } 186 | } 187 | }) 188 | } 189 | /** 190 | * 是否包含某个点 191 | */ 192 | contain (x: number, y: number) { 193 | // 首先要被裁剪路径包含 194 | if (this.clip) { 195 | if (!this.clip.contain(x, y)) { 196 | return 197 | } 198 | } 199 | //... 200 | } 201 | } 202 | ``` 203 | 应用 204 | ```typescript 205 | group1.add(rect) 206 | rect.setClip(curve) 207 | xr.add(group1) 208 | ``` 209 | 结果如图: 210 | 211 | ![](./images/v7/clip.png) 212 | 213 | 简单测试了一下其它方面也没有出问题。 214 | 215 | 现在我们回到`Text`。有了以上的裁剪基础,就可以实现最开始想要的功能了。 216 | 217 | 添加参数如下 218 | ```typescript 219 | interface TextShape extends XElementShape { 220 | /** 221 | * 此`maxWidth`不同于`canvas`绘制时的`maxWidth`,用来控制换行和省略的 222 | */ 223 | maxWidth?: number 224 | maxHeight?: number 225 | /** 226 | * 是否允许换行,默认不换行 227 | */ 228 | wrap?: boolean 229 | /** 230 | * 超出部分如何显示 231 | */ 232 | overflow?: 'hidden' | 'visible' | 'ellipsis' 233 | } 234 | ``` 235 | 为指定的宽高创建裁剪路径 236 | ```typescript 237 | class Text { 238 | updateOptions(opt?) { 239 | super.updateOptions(opt) 240 | this.updateClipRect() 241 | } 242 | updateClipRect () { 243 | if (this.shape.overflow === 'visible') { 244 | return 245 | } 246 | let shape = this.shape 247 | let opt = { 248 | scale: clone(this.scale), 249 | rotation: this.rotation, 250 | position: clone(this.position), 251 | origin: clone(this.origin), 252 | shape: { 253 | x: shape.x, 254 | y: shape.y, 255 | width: shape.maxWidth ? shape.maxWidth : 100000000000000, 256 | // 应该是文本的高度,暂时忽略 257 | height: shape.maxHeight ? shape.maxHeight : 100000000000000 258 | } 259 | } 260 | let rect = this.clip 261 | if (!rect) { 262 | rect = new Rect(opt) 263 | this.setClip(rect) 264 | } else { 265 | rect.attr(opt) 266 | } 267 | } 268 | } 269 | ``` 270 | 效果如图: 271 | 272 | ![](images/v7/clip-text.png) 273 | 274 | 接下来就可以准备换行和省略号的事宜了,开始之前想一想,对于文本超出给定高度的情况如何处理?是超出隐藏,还是多行省略(可能会有很多不必要的空白),或者可以滚动(做肯定是可以做,但是多半走远了)?暂时选择前两者吧。 275 | #### 超出显示省略号 276 | `canvas`提供了`mesureText`方法来检测给定文本的宽度,而且结果非常精准。因此,对于超出显示省略号的功能,可以先计算出省略号的宽度,然后用给定的最大宽度减去省略号宽度去找出文本换行的分界线。 277 | 278 | 准备一些辅助方法,创建`util/text.ts`: 279 | ```typescript 280 | // 按行绘制 281 | export interface LineText { 282 | x: number 283 | y: number 284 | text: string 285 | width: number 286 | } 287 | function createLineText (x, y, text, width) { 288 | return { 289 | x, 290 | y, 291 | text, 292 | width 293 | } 294 | } 295 | const ellipsis = '...' 296 | /** 297 | * 获取换行后的文本 298 | */ 299 | export function getWrapText( 300 | startX: number, startY: number, ctx: CanvasRenderingContext2D, 301 | text: string, shape: TextShape, style: XElementStyle 302 | ): LineText[] { 303 | // 没有指定宽度的话直接返回即可,没有指定宽度的话指定了高度也没用 304 | if (!shape.maxWidth) { 305 | return [createLineText(startX, startY, text)] 306 | } 307 | let result = [] 308 | let len = text.length 309 | let maxWidth = shape.maxWidth 310 | // 省略号的长度 311 | let ellipsisLength = ctx.measureText(ellipsis).width 312 | // 首先,不换行 313 | if (!shape.wrap || (lineHeight * 2 > maxHeight)) { 314 | switch (shape.overflow) { 315 | // 可见和隐藏不需要做更多 316 | case 'visible': 317 | case 'hidden': 318 | result = [createLineText(startX, startY, text, 0)] 319 | // 省略号算出长度即可 320 | case 'ellipsis': 321 | let { index, width } = findTextIndex(ctx, text, maxWidth) 322 | // 如果当前宽度不能满足需要,则添加省略号 323 | if (index < len - 1) { 324 | let indexData = findTextIndex(ctx, text, maxWidth - ellipsisLength) 325 | text = indexData.text + ellipsis 326 | width = indexData.width + ellipsisLength 327 | } 328 | result = [createLineText(startX, startY, text, width)] 329 | } 330 | } else { 331 | // ... 332 | } 333 | } 334 | 335 | interface TextIndex { 336 | /** 337 | * 索引 338 | */ 339 | index: number 340 | /** 341 | * 文本片段 342 | */ 343 | text: string 344 | /** 345 | * 文本片段的宽度 346 | */ 347 | width: number 348 | } 349 | 350 | /** 351 | * 根据最大宽度找到索引 352 | */ 353 | function findTextIndex (ctx: CanvasRenderingContext2D, text: string, maxWidth: number): TextIndex { 354 | if (!text) { 355 | return null 356 | } 357 | let measureText = text => ctx.measureText(text) 358 | let len = text.length 359 | let textWidth = measureText(text).width 360 | let result: TextIndex = { 361 | index: len - 1, 362 | text, 363 | // 返回宽度是因为后面要用到 364 | width: textWidth 365 | } 366 | // 宽度已经满足要求 367 | if (textWidth <= maxWidth) { 368 | return result 369 | } 370 | // 取中间的索引 371 | let halfLen = Math.floor(len / 2) 372 | let halfText = text.slice(0, halfLen ? halfLen : 1) 373 | result.text = halfText 374 | textWidth = measureText(halfText).width 375 | result.width = textWidth 376 | // 同上 377 | if (textWidth === maxWidth) { 378 | result.index = (halfLen ? halfLen : 1) - 1 379 | return result 380 | } 381 | // 如果文本一半的宽度小于最大宽度,向后取 382 | if (textWidth < maxWidth) { 383 | let nextIndex = findTextIndex(ctx, text.slice(halfLen), maxWidth - textWidth) 384 | if (nextIndex !== null) { 385 | halfLen += (nextIndex.index + 1) 386 | result.text += nextIndex.text 387 | result.width += nextIndex.width 388 | } 389 | result.index = halfLen - 1 390 | return result 391 | } 392 | // 分到第一个还无法满足需求 393 | if (halfLen === 0) { 394 | return null 395 | } 396 | // 如果一半仍然大于,向前取 397 | return findTextIndex(ctx, text.slice(0, halfLen - 1), maxWidth) 398 | } 399 | /** 400 | * 绘制文本 401 | */ 402 | export function renderText (ctx: CanvasRenderingContext2D, lineTexts: LineText[], method: string) { 403 | let lineText 404 | for (let i = 0; i < lineTexts.length; i += 1) { 405 | lineText = lineTexts[i] 406 | ctx[method](lineText.text, lineText.x, lineText.y) 407 | } 408 | } 409 | ``` 410 | 然后应用: 411 | ```typescript 412 | class Text { 413 | render (ctx: CanvasRenderingContext2D) { 414 | // ... 415 | let lineTexts = getWrapText(x, y, ctx, shape.text, shape, this.style) 416 | if (this.hasFill()) { 417 | renderText(ctx, lineTexts, 'fillText') 418 | } 419 | if (this.hasStroke()) { 420 | renderText(ctx, lineTexts, 'strokeText') 421 | } 422 | } 423 | } 424 | ``` 425 | 结果如图(为了效果明显,加上了背景色): 426 | 427 | ![](./images/v7/text-ellipsis.png) 428 | #### 换行 429 | 通过上面的方法,我们可以很容易找出需要换行的边界索引,但是想要分批绘制,还需要知道一个关键的信息,那就是行高。`canvas`里的行高是多少?上图的字体大小为`48px`,背景矩形的高度也为`48px`,看起来就和`line-height: 1`的效果相同,即行高等于字体大小。看起来似乎是这么回事。然而如果我们改变字体,`font-family: Arial`,那么结果如下: 430 | ![](./images/v7/text-aria.png) 431 | 432 | 可以很明显地看出,它的行高要远远超出字体大小——当然,这可能是因为字体太大了,但是可以确定的是,它的行高和字体高度不同。 433 | 434 | 而`canvas`是没有提供检测行高的方法的,那么可不可以借助其它方式呢?比如借助`HTML`的`getComputedStyle`?这个想法很美好,然而一个残酷的点是,`HTML`中的行高并不会因为字体的改变而改变,这是因为行高的含义本来就不是文字所占有的高度,而我们要绘制多行文本,需要的恰好就是文字所占高度。 435 | 436 | 而这是可以计算出来的。如何计算?首先看两张图片: 437 | ![](images/v7/font-height-1.png)![](images/v7/font-height-2.png) 438 | 439 | 第一张图字体为`serif`,第二张图字体为`Arial`,可以看出最接近底部的字符不是`g(英文字母g, \u0067)`就是`ŋ(\u014B)`,虽然没有测试其它字体,但是想来都差不多。同样的道理,可以使用字符`家`来测量字体的最高点。也就是说,如果能计算出`g`和`ŋ`最底部所在的位置(`bottom`),取其大者,再加个`1`到`2px`,和`家`最顶部的位置(`top`),那么当前字体设置下在`y`轴上最多占用多少个像素也就能够知道了。 440 | 441 | 上面的字都是以`textBaseline = top`为基准绘制的,同样的如果以`bottom`为基准来绘制,则可以获取最底部到空白分界线的位置(`bottomOffset`)。 442 | 443 | 那么怎么得出这些数据呢?我能想到的办法是,使用像素点来判断。首先用`clearRect`在画布左上角清空出一区域,在`(0, 0)`处绘制`g`,调用`getImageData`获取这片区域的像素点,假设当前的`fontSize`为`48`,查询第`48`行是否有像素点,如果没有,向前查找,直到找到有像素点的行为止。如果有,向后查找,找到没有像素点的行为止。`家`从`0`开始,同理。对于`bottomOffset`也是差不多的做法。 444 | 445 | 实际上,经过测试几种字体和文字,得出结果是,只要指定了字体和字体大小,大部分常用的文字(汉字、英文、标点)的`bottom + bottomOffset`的值都相同,且这个值基本等同于我们要找的`lineHeight`。而其它稀奇古怪的符号则会超出这个值,比如`႟ (\u109f)`和`ŋ`(对于`top`值同样如此,不过偏差并不多)。那么该如何抉择呢?计算每一个字的高度是否可行呢?答案是不行,即使是文字不多的情况,也会占用大量的时间。所以,为了大多数情况下的美观,我决定不考虑其它字符的情况。 446 | 447 | ![](./images/v7/font-data.png) 448 | 449 | 450 | 既然`bottom + bottomOffset`就可以确定行高,那么要`top`值做什么呢?如果以`top`为基准来绘制文本,那么顶部一般会留一定的空白,有时候又不想要这些空白,想让文本(指本行中最高的字,下同)紧贴顶部,在绘制文本时就需要减去`top`的值来决定纵坐标。虽然可能也会想让文本紧贴底部,但是一般来说当以`bottom`为基准绘制时,文本本身已经比较紧贴了,只有很短的距离。 451 | - 需要留有空白。 452 | 453 | ![](images/v7/top-bottom-white.png) 454 | - 不需要留有空白。 455 | 456 | ![](images/v7/bottom-white.png) 457 | 458 | **但是**,本文并不打算给出可配置的选项,为了简单,采用不留空白的方式来绘制,空白可以通过添加行间距的配置来形成,不过这里就不做这一项了。 459 | 460 | 说了这么多,究竟该如何计算呢? 461 | 462 | 具体方法如下: 463 | ```typescript 464 | // util/text.ts 465 | let ctxForLineHeight: CanvasRenderingContext2D 466 | let canvasForLineHeight: HTMLCanvasElement 467 | function createCtx () { 468 | canvasForLineHeight = document.createElement('canvas') 469 | // 一般来说测量行高应该够用了 470 | canvasForLineHeight.width = 1000 471 | canvasForLineHeight.height = 1000 472 | // 是的,即使display为none,也能取到数据 473 | canvasForLineHeight.style.display = 'none' 474 | document.body.appendChild(canvasForLineHeight) 475 | ctxForLineHeight = canvasForLineHeight.getContext('2d') 476 | } 477 | /** 478 | * 用字体和比值计算 479 | */ 480 | const FontsData = { 481 | example: { 482 | fontSize: 12, 483 | lineHeight: 14, 484 | top: 0 485 | } 486 | } 487 | const chars = { 488 | // g 489 | g: '\u0067', 490 | // ŋ 491 | n: '\u014B', 492 | // 家 493 | q: '\u5bb6' 494 | } 495 | /** 496 | * 将imageData转为二维数组 497 | */ 498 | function sliceImageData (data: Uint8ClampedArray, width: number, height: number) { 499 | let result = [] 500 | let len = 0 501 | for (let i = 0; i < height; i += 1) { 502 | let row = result[i] = [] 503 | for (let j = 0; j < width; j += 1) { 504 | row.push([ 505 | data[len++], 506 | data[len++], 507 | data[len++], 508 | data[len++] 509 | ]) 510 | } 511 | } 512 | 513 | return result 514 | } 515 | /** 516 | * 查询某一行是否有像素 517 | */ 518 | function hasPx (data: any[], row: number) { 519 | return data[row].filter(item => item[3]).length !== 0 520 | } 521 | /** 522 | * 获取单个字符的高度 523 | */ 524 | function getCharRange (char: string, width: number) { 525 | // 可能会超出范围 526 | let clearWidth = 1.5 * width 527 | ctxForLineHeight.clearRect(0, 0, clearWidth, clearWidth) 528 | // 在顶部画字 529 | ctxForLineHeight.textBaseline = 'top' 530 | // 画一个字 531 | ctxForLineHeight.fillText(char, 0, 0) 532 | // 获取它该有的像素 533 | let imgData = sliceImageData( 534 | ctxForLineHeight.getImageData(0, 0, clearWidth, clearWidth).data, 535 | clearWidth, 536 | clearWidth 537 | ) 538 | let top = 0 539 | let bottom = width - 1 540 | let rowHasPx = hasPx(imgData, bottom) 541 | if (rowHasPx) { 542 | // 向后取直到没有像素为止 543 | while ((bottom < clearWidth) && hasPx(imgData, bottom)) { 544 | bottom += 1 545 | } 546 | } else { 547 | // 向前取 548 | while ((bottom >= 0) && !hasPx(imgData, bottom)) { 549 | bottom -= 1 550 | } 551 | } 552 | while ((top < clearWidth) && !hasPx(imgData, top)) { 553 | top += 1 554 | } 555 | // 在底部画字 556 | ctxForLineHeight.clearRect(0, 0, clearWidth, clearWidth) 557 | ctxForLineHeight.textBaseline = 'bottom' 558 | ctxForLineHeight.fillText(char, 0, clearWidth) 559 | imgData = sliceImageData( 560 | ctxForLineHeight.getImageData(0, 0, clearWidth, clearWidth).data, 561 | clearWidth, 562 | clearWidth 563 | ) 564 | let bottomOffset = clearWidth - 1 565 | while ((bottomOffset >= 0) && !hasPx(imgData, bottomOffset)) { 566 | bottomOffset -= 1 567 | } 568 | bottomOffset = clearWidth - bottomOffset 569 | return { 570 | top, 571 | bottom, 572 | bottomOffset, 573 | lineHeight: bottom + bottomOffset 574 | } 575 | } 576 | /** 577 | * 根据当前样式获取文本行高和偏移 578 | */ 579 | export function getFontData (style: XElementStyle) { 580 | if (!ctxForLineHeight) { 581 | createCtx() 582 | } 583 | let fontFamily = style.fontFamily 584 | let fontData = FontsData[fontFamily] 585 | if (fontData) { 586 | return { 587 | lineHeight: fontData.lineHeight / fontData.fontSize * style.fontSize, 588 | top: fontData.top / fontData.fontSize * style.fontSize 589 | } 590 | } 591 | let font = `${style.fontSize}px ${fontFamily}` 592 | 593 | ctxForLineHeight.save() 594 | ctxForLineHeight.font = font 595 | ctxForLineHeight.setTransform(1, 0, 0, 1, 0, 0) 596 | // 重置样式 597 | ctxForLineHeight.textBaseline = 'top' 598 | ctxForLineHeight.fillStyle = '#000' 599 | // 这是国字的高度 600 | let width = ctxForLineHeight.measureText('国').width 601 | let lineHeight = getCharRange(chars.g, width).lineHeight 602 | let top = getCharRange(chars.q, width).top 603 | lineHeight -= top 604 | // 再加1px以免超出 605 | lineHeight += 1 606 | ctxForLineHeight.restore() 607 | FontsData[fontFamily] = { 608 | fontSize: style.fontSize, 609 | lineHeight, 610 | top 611 | } 612 | 613 | return FontsData[fontFamily] 614 | } 615 | ``` 616 | 得出了行高,就可以开始写换行的代码了。事实上得出行高的数据之后,不仅可以使用`maxHeight`来限制,也可以使用行数来限制,二者其实是等同的。~~同样的,为`Text`设置的裁剪框也能更加精确了——为什么?因为`maxHeight`本身的含义就是最大高度,而不是指定它的高度,有了行高之后,如果设置了最大高度,我们可以得到更准确的高度数据。所以将这一步放到设置`clip`之前进行。~~ 617 | ```typescript 618 | class Text { 619 | fontData = { 620 | top: 0, 621 | lineHeight: 12 622 | } 623 | // 在设置裁剪路径前获取 624 | updateOptions(opt?: TextOptions) { 625 | super.updateOptions(opt) 626 | opt = opt || this.options 627 | const fontData = this.fontData = getFontData(this.style) 628 | const shape = opt.shape 629 | // 有时候就是想 630 | // 二者只能取一个 631 | if (shape.rows) { 632 | this.shape.maxHeight = this.shape.rows * fontData.lineHeight 633 | } 634 | this.updateClipRect() 635 | } 636 | // render时将数据传入即可 637 | // 需要注意的是y = y - this.fontData.top 638 | } 639 | ``` 640 | 然后是开启换行时如何获取数据: 641 | ```typescript 642 | // util/text.ts 643 | export function getWrapText( 644 | startX: number, startY: number, ctx: CanvasRenderingContext2D, 645 | text: string, shape: TextShape, style: XElementStyle, lineHeight: number 646 | ): LineText[] { 647 | // 没有指定宽度的话直接返回即可,没有指定宽度的话指定了高度也没用 648 | if (!shape.maxWidth) { 649 | return [createLineText(startX, startY, text), ctx.measureText(text).width] 650 | } 651 | let result = [] 652 | let len = text.length 653 | let maxWidth = shape.maxWidth 654 | let maxHeight = shape.maxHeight 655 | // 省略号的长度 656 | let ellipsisLength = ctx.measureText(ellipsis).width 657 | // 首先,不换行 658 | if (!shape.wrap || (lineHeight * 2 > maxHeight)) { 659 | //... 660 | } else { 661 | let startIndex = 0 662 | let indexData = findTextIndex(ctx, text, maxWidth) 663 | let index = indexData.index 664 | result.push(createLineText(startX, startY, indexData.text, indexData.width)) 665 | while (index < len) { 666 | startIndex = index + 1 667 | indexData = findTextIndex(ctx, text.slice(startIndex), maxWidth) 668 | if (!indexData) { 669 | break 670 | } 671 | index += (indexData.index + 1) 672 | startY += lineHeight 673 | let subText = text.slice(startIndex, index) 674 | // 最后一段超出高度,在后面加省略号,即多行超出省略 675 | if (startY + lineHeight * 2 > maxHeight && (shape.overflow === 'ellipsis')) { 676 | indexData = findTextIndex(ctx, subText, maxWidth) 677 | if (!indexData) { 678 | break 679 | } 680 | // 如果当前宽度不能满足需要,则添加省略号 681 | if (indexData.index < subText.length - 1 || (index < len - 1)) { 682 | indexData = findTextIndex(ctx, subText, maxWidth - ellipsisLength) 683 | indexData.text += ellipsis 684 | indexData.width += ellipsisLength 685 | } 686 | result.push(createLineText(startX, startY, indexData.text, indexData.width)) 687 | break 688 | } 689 | result.push(createLineText(startX, startY, indexData.text, indexData.width)) 690 | } 691 | } 692 | ctx.restore() 693 | 694 | return result 695 | } 696 | } 697 | ``` 698 | 设置`rows`为2,结果如图: 699 | 700 | ![](./images/v7/text-wrap-1.png) ![](./images/v7/text-wrap-2.png) 701 | 702 | 设置`maxHeight`为`2.9 * lineHeight`: 703 | 704 | ![](./images/v7/text-wrap-3.png) 705 | 706 | 应该可以说是比较精准了。 707 | ### 修复动画的一个bug 708 | 使用动画的时候发现了一个`bug`,它会因为`XElment.shape`而影响到`XElement.options`,导致需要判断`options`时会出错。现修复如下: 709 | ```typescript 710 | // util/index.ts 711 | /** 712 | * 从原始属性中提取要使用动画的属性,返回一个新对象,否则可能对原来的opt造成污染 713 | */ 714 | export function getAnimationTarget (origin: Object, target: Object) { 715 | const animationTarget = {} 716 | for (let key in target) { 717 | if (isObject(target[key]) && isObject(origin[key])) { 718 | animationTarget[key] = getAnimationTarget(origin[key], target[key]) 719 | } else { 720 | animationTarget[key] = origin[key] 721 | } 722 | } 723 | 724 | return animationTarget 725 | } 726 | class XElement { 727 | animateTo () { 728 | // ... 729 | this.animation = new Animation(getAnimationTarget(this, target)) 730 | // ... 731 | } 732 | } 733 | ``` 734 | ### 内边距和填充 735 | 上图有一个问题,就是文本和边缘太过紧密(这是为了精确计算所不能少的),一般来说会需要它留有一定的间距。再考虑现在想实现一个非常简单的带文本的矩形按钮,应该怎么做?可以用`Group`组合`Rect`和`Text`,但其实容易想到的是,文本本身也是可以有边距和背景的——为它创建一个包围盒,就像`html`中的`p`标签等。也许这样会简单一些。这样的话`Text`也能响应事件了。为此需要计算文本的高度,并重新计算裁剪路径的数据——正好之前裁剪路径就设计得不够完整。如果一切顺利,那么结果应该如下图: 736 | 737 | ![](images/v7/text-padding-boudingrect.png) 738 | 739 | 首先为文本创建一个包围盒`boungdingRect`,并在`shape`加入`padding`选项——为什么放在`shpae`而不是`style`中?因为,我也不知道。解析选项和绘制很简单,需要注意的是绘制的时机必须在绘制裁剪路径前绘制包围盒。代码如下: 740 | ```typescript 741 | // util/text.ts 742 | /** 743 | * 解析padding为标准格式 744 | */ 745 | export function parsePadding (padding: number[] | number) { 746 | let result = [] 747 | if (Array.isArray(padding)) { 748 | switch (padding.length) { 749 | case 1: 750 | padding = padding[0] 751 | break 752 | case 2: 753 | case 3: 754 | result[0] = padding[0] 755 | result[1] = padding[1] 756 | result[2] = padding[0] 757 | result[3] = padding[1] 758 | break 759 | default: 760 | result = padding.slice(0, 4) 761 | break 762 | } 763 | } 764 | if (!result.length) { 765 | result = [padding, padding, padding, padding] 766 | } 767 | 768 | return result 769 | } 770 | // Text.ts 771 | interface TextShape extends XElementShape { 772 | /** 773 | * 边距 774 | */ 775 | padding?: number[] | number 776 | } 777 | interface TextOptions extends XElementOptions { 778 | shape?: TextShape 779 | /** 780 | * 为包围盒应用的样式 781 | */ 782 | rectStyle?: XElementStyle 783 | } 784 | class Text { 785 | boundingRect: Rect 786 | lineTexts: LineText[] = [] 787 | rectStyle: XElementStyle = { 788 | fill: 'none', 789 | stroke: 'none', 790 | lineWidth: 1, 791 | opacity: 1, 792 | cursor: 'pointer', 793 | fontSize: 12, 794 | fontFamily: 'serif', 795 | textAlign: 'left', 796 | textBaseline: 'top' 797 | } 798 | updateOptions(opt?: TextOptions) { 799 | super.updateOptions(opt) 800 | opt = opt || this.options 801 | if (opt.rectStyle) { 802 | merge(this.rectStyle, opt.rectStyle) 803 | } 804 | // ... 805 | } 806 | updateClipRect (x: number, y: number, width: number, height: number) { 807 | let opt = { 808 | scale: clone(this.scale), 809 | rotation: this.rotation, 810 | position: clone(this.position), 811 | origin: clone(this.origin), 812 | shape: { 813 | x, 814 | y, 815 | width, 816 | height 817 | } 818 | } 819 | let rect = this.clip 820 | if (!rect) { 821 | rect = new Rect(opt) 822 | this.setClip(rect) 823 | } else { 824 | rect.attr(opt) 825 | } 826 | } 827 | updateBoundingRect (x: number, y: number, textWidth: number, textHeight: number, padding: number[]) { 828 | let opt = { 829 | scale: clone(this.scale), 830 | rotation: this.rotation, 831 | position: clone(this.position), 832 | origin: clone(this.origin), 833 | shape: { 834 | x, 835 | y, 836 | width: textWidth + padding[1] + padding[3], 837 | height: textHeight + padding[0] + padding[2] 838 | }, 839 | style: this.rectStyle, 840 | relativeGroup: this.relativeGroup 841 | } 842 | let rect = this.boundingRect 843 | if (!rect) { 844 | rect = new Rect(opt) 845 | } else { 846 | rect.attr(opt) 847 | } 848 | // 它和Text用相同的变换 849 | if (this.parent) { 850 | // 仅仅用于绘制时判断,所以不需要调用setParent 851 | rect.parent = this.parent 852 | } 853 | 854 | this.boundingRect = rect 855 | } 856 | beforeRender (ctx: CanvasRenderingContext2D) { 857 | super.beforeRender(ctx) 858 | } 859 | render (ctx: CanvasRenderingContext2D) { 860 | if (this.hasFill()) { 861 | renderText(ctx, this.lineTexts, 'fillText') 862 | } 863 | if (this.hasStroke()) { 864 | renderText(ctx, this.lineTexts, 'strokeText') 865 | } 866 | } 867 | beforeSetCtxClip (ctx: CanvasRenderingContext2D) { 868 | // 虽然放在这里不是很合适,但是目前只能想到这么做来避免被裁剪掉 869 | let shape = this.shape 870 | let x = shape.x 871 | let y = shape.y 872 | let padding = parsePadding(this.shape.padding) 873 | x += padding[3] 874 | y += padding[0] 875 | let lineHeight = this.fontData.lineHeight 876 | let lineTexts = getWrapText( 877 | // 这里就减去top 878 | x, y - this.fontData.top, ctx, shape.text, 879 | shape, this.style, lineHeight, padding 880 | ) 881 | 882 | this.lineTexts = lineTexts 883 | this.updateClipRect(x, y, shape.maxWidth, lineTexts.length * lineHeight) 884 | x -= padding[3] 885 | y -= padding[0] 886 | this.updateBoundingRect(x, y, shape.maxWidth, lineTexts.length * lineHeight, padding) 887 | this.boundingRect.refresh(ctx) 888 | this.clip.refresh(ctx) 889 | } 890 | setCtxClip (ctx: CanvasRenderingContext2D) { 891 | this.beforeSetCtxClip(ctx) 892 | super.setCtxClip(ctx) 893 | } 894 | contain (x: number, y: number) { 895 | // 变换和描边等 896 | return this.boundingRect.contain(x, y) 897 | } 898 | getBoundingRect () { 899 | return this.boundingRect.getBoundingRect() 900 | } 901 | } 902 | 903 | // uilt/text.ts 904 | function getWrapText () { 905 | // ... 906 | let maxWidth = shape.maxWidth - padding[1] - padding[3] 907 | let maxHeight = (shape.maxHeight || 100000000) - padding[0] - padding[2] + startY 908 | } 909 | ``` 910 | 现在代码可以跑起来了,但是还有两个问题。 911 | 912 | 第一个问题,现在裁剪路径的定位是`(x, y)`,在`textBaseLine = top, textAlign = left`时表现正常,然而更改这些定位方式后,包围盒就会变得很奇怪。解决办法也很简单,根据上面两个属性的值结合`lineHeight`来进行调整即可。为了方便计算,需要限定这两个属性的值。 913 | ```typescript 914 | // XElement.ts 915 | function bindStyle () { 916 | // ... 917 | let textBaseline = style.textBaseline 918 | if (['top', 'middle', 'bottom'].indexOf(textBaseline) === -1) { 919 | // 默认为顶部 920 | textBaseline = 'top' 921 | // 要更新style里的值 922 | style.textBaseline = textBaseline 923 | } 924 | ctx.textBaseline = 'top' 925 | let textAlign = style.textAlign 926 | if (['left', 'center', 'right'].indexOf(textAlign) === -1) { 927 | // 默认为左侧 928 | textAlign = 'left' 929 | style.textAlign = style.textAlign 930 | } 931 | ctx.textAlign = 'left' 932 | } 933 | 934 | ``` 935 | 然后根据之前的`getWrapText`中返回文本的长度,最后就可以根据这些来调整了。 936 | 937 | 与此同时,对于宽高限制,新增`width`和`height`属性,和`max*`的区别是: 938 | - 认为在使用`maxHeight`和`maxWidth`时,`Text`的包围盒和裁剪路径将自动形成,只不过不会超过给定的限制,且它们已经包含了内边距。 939 | - `width`和`height`同样具有限制的效果,但是它们会指定包围盒和裁剪路径的大小。 940 | 941 | 代码如下: 942 | ```typescript 943 | class Text { 944 | beforeSetCtxClip (ctx: CanvasRenderingContext2D) { 945 | // 虽然放在这里不是很合适,但是目前只能想到这么做来避免被裁剪掉 946 | let shape = this.shape 947 | let x = shape.x 948 | let y = shape.y 949 | let clipX = x 950 | let clipY = y 951 | let padding = parsePadding(this.shape.padding) 952 | let lineHeight = this.fontData.lineHeight 953 | clipY += padding[0] 954 | clipX += padding[3] 955 | let lineTexts = getWrapText( 956 | // 这里就减去top 957 | clipX, clipY - this.fontData.top, ctx, shape.text, 958 | shape, this.style, lineHeight, padding 959 | ) 960 | let textWidth = Math.max(...lineTexts.map(lineText => lineText.width)) 961 | let textHeight = lineTexts.length * lineHeight 962 | 963 | let clipWidth = textWidth 964 | let clipHeight = textHeight 965 | let boundingRectWidth = textWidth + padding[1] + padding[3] 966 | let boundingRectHeight = textHeight + padding[0] + padding[2] 967 | if (shape.height) { 968 | clipHeight = shape.height - padding[0] - padding[2] 969 | boundingRectHeight = shape.height 970 | } 971 | if (shape.width) { 972 | clipWidth = shape.width - padding[1] - padding[3] 973 | boundingRectWidth = shape.width 974 | } 975 | 976 | let offsetX = 0 977 | let offsetY = 0 978 | switch (this.style.textAlign) { 979 | case 'right': 980 | offsetX = -boundingRectWidth 981 | break 982 | case 'center': 983 | offsetX = -boundingRectWidth / 2 984 | break 985 | case 'left': 986 | default: 987 | break 988 | } 989 | switch (this.style.textBaseline) { 990 | case 'bottom': 991 | offsetY = -boundingRectHeight 992 | break 993 | case 'middle': 994 | offsetY = -boundingRectHeight / 2 995 | break 996 | case 'top': 997 | default: 998 | break 999 | } 1000 | 1001 | clipX += offsetX 1002 | clipY += offsetY 1003 | x += offsetX 1004 | y += offsetY 1005 | this.lineTexts = lineTexts.map(lineText => { 1006 | lineText.y += offsetY 1007 | lineText.x += offsetX 1008 | 1009 | return lineText 1010 | }) 1011 | this.updateClipRect(clipX, clipY, clipWidth, clipHeight) 1012 | this.updateBoundingRect(x, y, boundingRectWidth, boundingRectHeight) 1013 | this.boundingRect.refresh(ctx) 1014 | } 1015 | } 1016 | ``` 1017 | 效果如图: 1018 | 1019 | ![](images/v7/text-wrap-padding-1.png) ![](images/v7/text-wrap-padding-2.png) ![](images/v7/text-wrap-padding-3.png) ![](images/v7/text-wrap-padding-4-visible.png) 1020 | 1021 | 虽然还有很多功能可以添加,不过这样的文本应该能满足一般的需求了,其它功能在此基础上简单添加即可,比如行间距,字间距。至此,`Text`,就已经创建完毕。 1022 | 1023 | ### 清理内存 1024 | 在之前的内容中我们一直都是在添加元素后就没有增删过,而如果要增删,就要管理好内存,否则可能会造成内存泄漏导致应用崩溃。就和前面提到的一样,为类添加`dispose`方法即可。代码内已经添加完毕,这里就不贴出来占地方了。 1025 | ## bug修复 1026 | 最开始设计的渲染逻辑和加入`Layer`后的有较大变化,而在添加`Layer`的版本中只关注了**改**时是否能正常变动,忽略了增删,因此存在一些`bug`,现修复如下。 1027 | ### delete 1028 | 之前的设计中,更新的时候`Layer`要该层有元素标记为`drity`才会更新,而如果将一个元素删除,`Layer`根本获取不到这个元素,也就无法更新了。当然,可以删除元素时调用`dirty`方法,重新绘制之后再将元素从`Stage`移除,但是`dirty`过后我们并不知道需要多久才能完成重绘,所以这个方法行不通。 1029 | 1030 | 另一个思路是为`Layer`保留结束索引,如果有有删除的元素,那么遍历元素时得到的结束索引和原先的结束索引应该不一致,不过能够改变这个值的因素太多,所以也不太行。 1031 | 1032 | 那么我能想到的方法是,调用`Storage.delete`时并不删除元素,而将此元素标记为待删除,更新时进行判断即可。 1033 | ```typescript 1034 | class XElement { 1035 | /** 1036 | * 自身所关联的stage 1037 | */ 1038 | __stage: Stage 1039 | /** 1040 | * 是否正在被删除,`Layer`遇到此标记等同于`drity`,然后调用删除自身的方法 1041 | * 而`Stage`删除元素时此标记为真会将此元素删除,否则标记为真,然后调用`dirty()` 1042 | */ 1043 | deleteing = false 1044 | removeSelf () { 1045 | this.__stage.delete(this) 1046 | } 1047 | } 1048 | class Stage { 1049 | add (...xelements: XElement[]) { 1050 | for (let i = 0; i < xelements.length; i += 1) { 1051 | xelements[i].__stage = this 1052 | this.xelements.push(xelements[i]) 1053 | } 1054 | } 1055 | delete (xel: XElement) { 1056 | let index = this.xelements.indexOf(xel) 1057 | if (index > -1) { 1058 | if (xel.deleteing) { 1059 | this.xelements.splice(index, 1) 1060 | } else { 1061 | xel.deleteing = true 1062 | xel.dirty() 1063 | } 1064 | } 1065 | } 1066 | } 1067 | class Painter { 1068 | updateLayerList (xelList: XElement[]) { 1069 | // ... 1070 | for (let i = 0; i < xelList.length; i += 1) { 1071 | let xel = xelList[i] 1072 | let layer = layerList[xel.zIndex] || this.createLayer(xel.zIndex) 1073 | if (xel.deleteing) { 1074 | layer._dirty = true 1075 | xel.removeSelf() 1076 | xelList.splice(i, 1) 1077 | i -= 1 1078 | continue 1079 | } 1080 | // ... 1081 | } 1082 | } 1083 | } 1084 | ``` 1085 | ### hide 1086 | 同上一个问题,在设置`ignored`后无法获取到相关元素,也就无从判断是否需要重绘。所以更改这一部分为`Stage.getAll`能获取到所有元素,而在`XElement.refresh`中如果其`ignored`为`true`,则什么也不做。调用`hide`时设置`dirty`。 1087 | 1088 | 相应的,在事件检测阶段和组的包围盒计算中应该略过`ignored`为真的元素。 1089 | > - [ ] 这样处理好像是有点问题。等待后续优化。 1090 | ### Layer.dispose 1091 | 在绘制阶段即使当前层没有元素关联了,也无需调用`Layer.dispose`。 1092 | ### BoundingRect.union 1093 | 现在传入的`rect`宽高为0的话将被忽略。 1094 | 1095 | ## 小结 1096 | 1097 | 从`V1`至今,`XRender`,已经有了7个版本,虽然还有种种不完善的地方比如比如渐变,比如图案填充,比如高倍屏下的模糊处理,比如`XElement`类的代码太多,可以做适当拆分,再比如`zrender`更重磅的功能——`svg`渲染,都没有做。但是可以说一个`canvas`库该有的功能和结构,它都已经具备了,只是需要完善一些细节以便更好地使用。 1098 | 1099 | 一路走来对内容修修补补,有些地方遵循`xrender`的良好设计,有些地方因为懒或者水平所限,留下了不少的瑕疵。不管怎么说,收获了很多。而关于`XRender`,关于`canvas`,到此就已经结束了。而从零打造`Echarts`这一工程却才完成了一小部。接下来,让我们一起进入`Echarts`的全新篇章吧。 1100 | 1101 | ## V8预览 1102 | 待续。 -------------------------------------------------------------------------------- /images/other/echarts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/other/echarts.png -------------------------------------------------------------------------------- /images/other/echarts_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/other/echarts_1.png -------------------------------------------------------------------------------- /images/other/readme/end.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/other/readme/end.png -------------------------------------------------------------------------------- /images/other/readme/home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/other/readme/home.png -------------------------------------------------------------------------------- /images/other/readme/readme_1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/other/readme/readme_1.gif -------------------------------------------------------------------------------- /images/other/readme/readme_2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/other/readme/readme_2.gif -------------------------------------------------------------------------------- /images/other/readme/readme_3.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/other/readme/readme_3.gif -------------------------------------------------------------------------------- /images/other/readme/readme_4.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/other/readme/readme_4.gif -------------------------------------------------------------------------------- /images/other/readme/readme_5.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/other/readme/readme_5.gif -------------------------------------------------------------------------------- /images/other/readme/readme_6.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/other/readme/readme_6.gif -------------------------------------------------------------------------------- /images/v2/v1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v2/v1.png -------------------------------------------------------------------------------- /images/v2/v2_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v2/v2_1.png -------------------------------------------------------------------------------- /images/v3/v3_easing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v3/v3_easing.png -------------------------------------------------------------------------------- /images/v4/v4_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v4/v4_1.png -------------------------------------------------------------------------------- /images/v4/v4_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v4/v4_2.png -------------------------------------------------------------------------------- /images/v4/v4_3.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v4/v4_3.gif -------------------------------------------------------------------------------- /images/v4/v4_4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v4/v4_4.png -------------------------------------------------------------------------------- /images/v4/v4_rect_scale1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v4/v4_rect_scale1.png -------------------------------------------------------------------------------- /images/v4/v4_rect_scale2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v4/v4_rect_scale2.png -------------------------------------------------------------------------------- /images/v4/v4_rect_scale_correct.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v4/v4_rect_scale_correct.png -------------------------------------------------------------------------------- /images/v5/v5_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v5/v5_1.png -------------------------------------------------------------------------------- /images/v5/v5_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v5/v5_2.png -------------------------------------------------------------------------------- /images/v5/v5_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v5/v5_3.png -------------------------------------------------------------------------------- /images/v5/v5_4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v5/v5_4.png -------------------------------------------------------------------------------- /images/v5/v5_6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v5/v5_6.png -------------------------------------------------------------------------------- /images/v5/v5_7.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v5/v5_7.gif -------------------------------------------------------------------------------- /images/v5/v5_8.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v5/v5_8.gif -------------------------------------------------------------------------------- /images/v6/arc-boudingrect.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v6/arc-boudingrect.png -------------------------------------------------------------------------------- /images/v6/arc-fill-rule.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v6/arc-fill-rule.png -------------------------------------------------------------------------------- /images/v6/arc-fill.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v6/arc-fill.png -------------------------------------------------------------------------------- /images/v6/arc-fill_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v6/arc-fill_1.png -------------------------------------------------------------------------------- /images/v6/arc-stroke.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v6/arc-stroke.png -------------------------------------------------------------------------------- /images/v6/box.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v6/box.png -------------------------------------------------------------------------------- /images/v6/circle-stroke.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v6/circle-stroke.png -------------------------------------------------------------------------------- /images/v6/circle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v6/circle.png -------------------------------------------------------------------------------- /images/v6/cursor-pointer.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v6/cursor-pointer.gif -------------------------------------------------------------------------------- /images/v6/curve-fill.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v6/curve-fill.png -------------------------------------------------------------------------------- /images/v6/curve.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v6/curve.png -------------------------------------------------------------------------------- /images/v6/curve_stroke.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v6/curve_stroke.png -------------------------------------------------------------------------------- /images/v6/drag-el.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v6/drag-el.gif -------------------------------------------------------------------------------- /images/v6/drag-group.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v6/drag-group.gif -------------------------------------------------------------------------------- /images/v6/line-fill.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v6/line-fill.png -------------------------------------------------------------------------------- /images/v6/line-fill_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v6/line-fill_1.png -------------------------------------------------------------------------------- /images/v6/line-fill_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v6/line-fill_2.png -------------------------------------------------------------------------------- /images/v6/line-stroke.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v6/line-stroke.png -------------------------------------------------------------------------------- /images/v6/nozero_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v6/nozero_1.png -------------------------------------------------------------------------------- /images/v6/nozero_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v6/nozero_2.png -------------------------------------------------------------------------------- /images/v6/nozero_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v6/nozero_3.png -------------------------------------------------------------------------------- /images/v6/nozero_4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v6/nozero_4.png -------------------------------------------------------------------------------- /images/v6/nozero_5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v6/nozero_5.png -------------------------------------------------------------------------------- /images/v6/rect-point.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v6/rect-point.png -------------------------------------------------------------------------------- /images/v6/transform.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v6/transform.png -------------------------------------------------------------------------------- /images/v7/bottom-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v7/bottom-white.png -------------------------------------------------------------------------------- /images/v7/clip-text.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v7/clip-text.png -------------------------------------------------------------------------------- /images/v7/clip.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v7/clip.png -------------------------------------------------------------------------------- /images/v7/font-data.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v7/font-data.png -------------------------------------------------------------------------------- /images/v7/font-height-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v7/font-height-1.png -------------------------------------------------------------------------------- /images/v7/font-height-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v7/font-height-2.png -------------------------------------------------------------------------------- /images/v7/text-aria.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v7/text-aria.png -------------------------------------------------------------------------------- /images/v7/text-baseline.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v7/text-baseline.png -------------------------------------------------------------------------------- /images/v7/text-baselint1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v7/text-baselint1.png -------------------------------------------------------------------------------- /images/v7/text-ellipsis.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v7/text-ellipsis.png -------------------------------------------------------------------------------- /images/v7/text-padding-boudingrect.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v7/text-padding-boudingrect.png -------------------------------------------------------------------------------- /images/v7/text-wrap-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v7/text-wrap-1.png -------------------------------------------------------------------------------- /images/v7/text-wrap-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v7/text-wrap-2.png -------------------------------------------------------------------------------- /images/v7/text-wrap-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v7/text-wrap-3.png -------------------------------------------------------------------------------- /images/v7/text-wrap-padding-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v7/text-wrap-padding-1.png -------------------------------------------------------------------------------- /images/v7/text-wrap-padding-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v7/text-wrap-padding-2.png -------------------------------------------------------------------------------- /images/v7/text-wrap-padding-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v7/text-wrap-padding-3.png -------------------------------------------------------------------------------- /images/v7/text-wrap-padding-4-visible.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v7/text-wrap-padding-4-visible.png -------------------------------------------------------------------------------- /images/v7/text-wrap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v7/text-wrap.png -------------------------------------------------------------------------------- /images/v7/top-bottom-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/images/v7/top-bottom-white.png -------------------------------------------------------------------------------- /math/v4/math.md: -------------------------------------------------------------------------------- 1 | $$ 显然OA = OA' $$ 2 | $$ 而OA = \frac{y_1}{sin(a)} = \frac{x_1}{cos(a)} $$ 3 | $$ 且OA' = \frac{y_2}{sin(a + b)} = \frac{x_2}{cos(a + b)}$$ 4 | $$ 令它们都等于r$$ 5 | $$则 y_2 = r * (sin(a) * cos(b) + cos(a) * sin(b)) = y_1 * cos(b) + x_1 * sin(b)$$ 6 | $$则 x_2 = r * (cos(a) * cos(b) - sina(a) * sin(b)) = x_1 * cos(b) - y_1 * sin(b)$$ 7 | --- 8 | $$ 9 | \begin{matrix} 10 | scaleX|cos(rotateZ) & tan(skewX)|-sin(rotateZ) & translateX \\ 11 | tan(skewY)|sin(rotateZ) & scaleY|cos(rotateZ) & translateY\\ 12 | 0 & 0 & 1 \\ 13 | \end{matrix} 14 | \,\,\,\, *\,\,\, 15 | \begin{matrix} 16 | x \\ 17 | y \\ 18 | 1 \\ 19 | \end{matrix} 20 | $$ -------------------------------------------------------------------------------- /math/v4/公式_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/math/v4/公式_1.png -------------------------------------------------------------------------------- /math/v4/公式_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/math/v4/公式_2.png -------------------------------------------------------------------------------- /math/v6/math.md: -------------------------------------------------------------------------------- 1 | $$ 2 | B(t) = (1 - t)^2P_0 + 2t(1 - t)P_1 + t^2P_2, t \in [0, 1] 3 | $$ 4 | 5 | ##### 二次 6 | > $$公式:B(t) = (1 - t)^2P_0 + 2t(1 - t)P_1 + t^2P_2, t \in [0, 1]$$ 7 | > $$求导:(2t - 2)P_0 + (2 - 4t)P_1 + 2tP_2 = 0$$ 8 | > $$化简:(P_0 - 2P_1 + P_2)t - P_0 + P_1 = 0$$ 9 | > 系数为0时易知`t = 0.5`时有极限值,否则 10 | > $$t = \frac{P_0 - P_1}{P_0 + P_2 - 2P_1}$$ 11 | 12 | ##### 三次 13 | > $$公式:B(t) = (1 - t)^3P_0 + t(1 - t)^2 3P_1 + t^2(1 - t)3P_2 + t^3P_3, t \in [0, 1]$$ 14 | > $$求导:(-3t^2 + 6t - 3)P_0 + (3t^2 - 4t + 1)3P_1 + (-3t^2 + 2t)3P_2 + 3t^2P_3 = 0$$ 15 | > $$化简:(-3P_0 + 9P_1 - 9P_2 + 3P_3)t^2 + (6P_0 - 12P_1 + 6P_2)t - 3P_0 + 3P_1 = 0$$ 16 | > $$令a = -3P_0 + 9P_1 - 9P_2 + 3P_3$$ 17 | > $$令b = 6P_0 - 12P_1 + 6P_2$$ 18 | > $$令c = - 3P_0 + 3P_1$$ 19 | > 同样的,首先考虑二次项系数a为0时 20 | > 1. 如果一次项b系数也为0,那么最大值无穷大。 21 | > 2. 否则有一个最值,此时 22 | > $$t = \frac{-c}{b}$$ 23 | > 然后就是求一元二次方程的解了,此处不再赘述。 24 | 25 | > $$假设两点为(x_0, y_0),(x_1, y_1),目标点为(x, y),线宽为l$$ 26 | > $$令a = \frac{y_0 - y_1}{x_0 - x_1},b = y_0 - ax_0$$ 27 | > $$则线段函数为Y(x) = ax + b$$ 28 | > $$代入x可得检测点和函数原始点的距离t = |ax + b - y|$$ 29 | > $$而y轴和线段所在直线的交叉长度s = \sqrt{(a^2 + 1)l^2}$$ 30 | > $$显然,如果t <= \frac{s}{2},则可判定包含$$ 31 | > $$即,t^2 <= \frac{s^2}{4}$$ 32 | 33 | $$ 34 | B(t) = (1 - t)^2P_0 + 2t(1 - t)P_1 + t^2P_2, t \in [0, 1] 35 | $$ 36 | 此处的t,并不等于 37 | $$ \frac{x - x_0}{x1 - x0}$$ 38 | 39 | > $$假设线段从(x_0, y_0)到(x_1, y_1),待检测点为(x, y)$$ 40 | > $$如果线段水平,即y_1 = y_0,并且y != y_1,那么不用再检测,返回0即可$$ 41 | > $$首先得出线段的方程为X(y) = \frac{x_0 - x_1}{y_0 - y_1}(y - y_0) + x_0$$ 42 | > $$那么水平线和线段的交点x' = X(y)$$ 43 | > $$如果x = x',可知此点就在线段上,返回无穷大$$ 44 | > $$如果x < x',可知线段待检测点的左侧,即没有交点,返回0$$ 45 | > $$如果x > x',如果y_1 < y_0(在canvas坐标系中y_1 > y_0),就像图中所示一样,为逆时针,返回-1,反之返回1$$ 46 | > $$以上面为基础,如果y_0 = y或者y = y_1,返回结果除以2$$ 47 | 可以写出如下代码。 48 | 49 | 50 | 51 | > $$设弧的圆心在(x_0, y_0),半径为r,角度为\alpha到\beta,待检测点坐标为(x, y)$$ 52 | > $$则可求出直线y=y与圆(不是弧)的交点(x', y), (x_1', y)$$ 53 | > $$取二者横坐标大于x的点(可能两个点都满足,下面假设只有一个),求出交点所在半径的角度\theta$$ 54 | > $$如果\theta在\alpha到\beta之间,则根据\theta的范围和圆弧是顺时针还是逆时针来决定返回1或-1,否则返回0$$ -------------------------------------------------------------------------------- /math/v6/公式_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/math/v6/公式_1.png -------------------------------------------------------------------------------- /math/v6/公式_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/math/v6/公式_2.png -------------------------------------------------------------------------------- /math/v6/公式_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/math/v6/公式_3.png -------------------------------------------------------------------------------- /math/v6/公式_4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/math/v6/公式_4.png -------------------------------------------------------------------------------- /math/v6/公式_5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/math/v6/公式_5.png -------------------------------------------------------------------------------- /math/v6/公式_6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/math/v6/公式_6.png -------------------------------------------------------------------------------- /math/v6/公式_7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webbillion/xrender-notes/95cf14ef934ff19d1dd194ab764e2279ed2b4609/math/v6/公式_7.png -------------------------------------------------------------------------------- /x_README.md: -------------------------------------------------------------------------------- 1 | > **旁白**:上一集(什么,这不是才第一集吗)我们说到,自`canvas`推出以来几经更迭,功能逐渐完善,加上`flash`的没落和现代浏览器的普及,越来越多的游戏和可视化应用选择使用`canvas`来开发。因此诞生了很多相关的框架,各门各类不知凡几,优秀作品更是不在少数。那么在这种环境下,于小跃的``xcharts``系列作品,功能趋同且残缺,代码混乱,设计糟糕,连造轮子都算不上(离它模仿的`echarts`相差不可以道理计),又凭什么从一众优秀作品中脱颖而出,成为后世楷模,人们争相拜读之作呢?``xcharts``究竟是什么,有什么作用?它有什么闪光点?它的作者于小跃又是怎样的人?而于小跃又为什么要写``xcharts``呢?欢迎易小天教授(划掉)做客本期一家讲坛,为大家带来从零打造`Echarts`系列讲座之``xcharts``的前世今生。 2 | 3 | ![](./images/other/readme/home.png) 4 | 5 | 大家好,我是易小天。 6 | 7 | 接上回啊,``xcharts``这样糟糕的的半成品凭什么脱颖而出呢?这需要从当时的环境说起,什么环境啊,`canvas`框架的发展环境。彼时`canvas`已经推出多年了,无论游戏引擎还是可视化图表都有相当成熟且优秀的框架 8 | 了,而且还不只一两个。它们的使用者也是相当得多,可以说当时几乎所有的前端开发者都有耳闻。 9 | 10 | 但是我们说,任何一个合格,或者说想要提升自己的开发者,不能只会使用框架对不对?必然要对框架原理甚至源码做深入了解,才能有所成长。而于小跃就是这样一个想提升自己的人,他觉得虽然看了`canvas`的`api`,但是怎么能做到`echarts`这样酷炫,完全没有头绪,只能徒生艳羡。所以就想了解它的原理和设计。 11 | 12 | 怎么去了解原理啊?实力强的看源码目录就了然于胸,实力弱的就只能看教程和分析了。实力强的多还是实力弱的多啊,那显然是弱的多,那时很多人都不能叫程序员,只能叫码农!这个词诸位可能不太了解,是一个比较古老的名词,意思就是,反正就是很弱鸡啦。而于小跃这个人呢,当时……怎么说呢,只能说没有弱到无可救药吧。 13 | 14 | 他第一反应就是搜啊,百度搜完谷歌搜。面向搜索学习有什么不对吗。诸位就会想了,既然这些框架的群众基础如此广泛,贡献者也这么多,相关分析应该不在少数吧? 15 | 16 | 于小跃也是这么想的。 17 | 18 | 至于结果,大家应该都能猜到了,结果寥寥。只能搜到一些如何实现粒子效果,或者介绍某个`canvas`库和其使用方法的。于小跃当时就蒙了:你一直叫我进步我怎么进步啊,网上为什么没有教程啊? 19 | 20 | 不知道各位有没有这样的疑问,为什么没有啊? 21 | 22 | 23 | > **旁白**:当时的`canvas`发展可谓百花齐放,而可视化图表领头羊的`echarts`则是其中佼佼者,作为百度系少有的广受好评的开源作品,其使用和研究者多不胜数,但是于小跃搜遍全网 24 | 也找不到好一点的分析教程,和当时其它领域,如`mvvm`框架`vue` `react`分析泛滥的情况截然不同。这究竟是为什么呢?这会不会是于小跃写``xcharts``的原因呢? 25 | 26 | 为什么没有?于小跃自己总结了三个原因。 27 | ![](./images/other/readme/readme_1.gif) 28 | 1. 其数理艰涩,教人者疲,而学者无所获。 29 | 什么意思呢?就是说这些框架涉及到较多且艰难晦涩(笔者注:对非科班码农而言)的数学知识,也许你自己滚瓜烂熟,但是你要写出来让别人也能理解,不是一件简单的事,耗费心神,而且啊,别人看了还不一定懂。 30 | 31 | ![](./images/other/readme/readme_2.gif) 32 | 2. `canvas`,小众也。 33 | 和`vue`,`react`等框架比起来,`echarts`的使用者还是较少的。或者说整个前端领域,经常使用`canvas`的开发者并不多。偶尔需要用到时,会用框架就行了,不需要多么了解。而就算是使用者,对框架原理感兴趣的也并不多。所以为此写分析的人自然也就少了。 34 | 35 | ![](./images/other/readme/readme_3.gif) 36 | 3. 或存,觅而不得。 37 | 这什么意思呢?就是说也许是有的,但是他没有搜到而已。为什么可能没有搜到呢?这就要介绍一下于小跃这个人了。 38 | 39 | 于小跃是什么样的人?前面说了,是一个菜鸡。问题是有多菜呢?他在``xcharts``的序言中写到。 40 | ![](./images/other/readme/readme_4.gif) 41 | - 余学土木,途中至此。 42 | 43 | 就是说他本来是学土木工程的,学到一半才转了前端。就是说他是一个非科班的码农。计算机基础不好。当然他说的比较含蓄。虽然他这么说,但是也可能是他自谦对不对?非科班不代表能力不行啊。但是他接下来又说了。 44 | 45 | ![](./images/other/readme/readme_5.gif) 46 | - 离家半载,成事不足,虽璞非玉。 47 | 48 | 这意思是才毕业半年,做成的事情也不多,也就是项目经验少,知道的少又笨。这应该不是自谦了吧。诶,你这个样子还来写分析教程?我告诉诸位,这还不是最过分的,最过分的是什么?\ 49 | ![](./images/other/readme/readme_6.gif) 50 | - 作此文时,探源起始。 51 | 52 | 哎呀,这不得了啊,他写这个文章的时候,他也才刚刚开始探究原理。就是这么一个人,他写的``xcharts``。 53 | 54 | 虽然上面没有明说,但是可以品味出来的一点是,他的英语可能不怎么样。所以,可能有英语的分析教程,但是他搜不到,或者搜到了也看不懂。 55 | 56 | 那么当时有没有英语的相关分析呢?应该是有的,不过时间过去太久了,很难考证。就此略过不提。 57 | 58 | > **旁白**:易小天教授为我们分析了于小跃找不到相关教程的原因,同时介绍了于小跃在当时的情况。虽然原因找到了,但是如何解决呢?作为一个弱鸡,在没有教程的情况下,他如何去探源呢? 59 | 60 | 虽然找不到相关分析,但还是得看不是,不能就此停住学习的脚步啊。但是前面也说了,于小跃是个菜鸡啊,自己去看源码?诶,没错,就是自己看。各位就会想了,他看得懂吗? 61 | 62 | 他勉强能看懂。这和之前提到的`vue`有关系。前面也说了,`vue`源码相关的分析是很多的,于小跃呢,恰好看过一些,自己也完整阅读过`vue`的源码,还写了笔记。大家有兴趣的可以去[了解一下](https://github.com/webbillion/vue-note)。 63 | 64 | 虽然二者源码不同,但是于小跃有过阅读源码的经验了,应该说也具备一些从高度解耦的代码中抽丝剥茧并组合的能力了,所以,他磕磕绊绊,还是能看懂`echarts`的源码的。 65 | 66 | 有的人可能就有疑问了,他看就看呗,这和``xcharts``有什么关系呢?他的笔记也应该叫`echarts`阅读笔记啊。没错,一般来说是这样,看源码,写分析笔记,很常见的流程。 67 | 68 | 但是于小跃这个人呢,懒。要他讲解`echarts`源码的话,首先自己的理解不一定对,其次那么多东西如何做取舍,常言道,去取之从来,他自己也不知道如何而来。所以他想到了另一个方式,自己实现一遍`echarts`。既能帮助自己深入理解,也能造福大众。 69 | 70 | 这也是``xcharts``和其它库的不同,它们的目的和受众人群完全不同,可以说``xcharts``是为了于小跃自己这样的人量身打造的,而这样的人应该说并不在少数。所以,它才能脱颖而出。 71 | 72 | 这,就是``xcharts``的由来。 73 | 74 | > **旁白**:本集中易小天教授为我们讲述了于小跃的情况和他所著``xcharts``诞生的由来,并简要分析了``xcharts``的特点。那么于小跃将会从何处着手,又以怎样的方式来创建``xcharts``,并做到通俗易懂,让和他自己一样水平的读者理解呢?欢迎收看下集,从零打造`Echarts`系列讲座之从再造`Zrender`开始。 75 | 76 | ![](./images/other/readme/end.png) 77 | 78 | > 注:以上用词如有不当,皆为节目效果,还请一笑而过。 79 | > 本系列只是趣味引入,真正的知识点请看[正文](https://github.com/webbillion/xrender-notes)。 -------------------------------------------------------------------------------- /x_Version1.md: -------------------------------------------------------------------------------- 1 | # `canvas`破苍穹 2 | ## (一)陨落的天才 3 | 4 | “`canvas`之力,三段!" 5 | 6 | 望着浏览器上暗淡无光的一段话,少年面无表情,唇角有着一抹自嘲(别问我怎么一边面无表情一般自嘲,我不知道)。 7 | 8 | ”`xcharts`,`canvas`之力,三段!级别:低级!“浏览器旁,一位中年程序员,看了眼控制台打印的语句,语气漠然地将之公布了出来。 9 | 10 | 秃头男(等等!刚刚不还只是中年程序员吗?)话刚刚脱口,便不出意外的在人头(?)汹涌的的机房带起一阵嘲讽的骚动。 11 | 12 | ”三段?嘿嘿,果然不出我所料,这个‘天才’还是只会`context.arc`!“ 13 | 14 | ”哎,这废物真是把我们`charts`界的脸都丢光了。“ 15 | 16 | ”真不知道`Echarts`大人从哪儿捡回来这么个废物!“ 17 | 18 | ”曾经他学`console`时进步多么神速啊,如今怎会愚笨至此!“ 19 | 20 | `xcharts`缓缓抬起头来,露出一张有些清秀的稚嫩脸庞,漆黑的眸子木然从那些嘲讽的同龄人身上扫过,嘴角的自嘲,似乎更加苦涩了。 21 | 22 | ”这些人,都如此刻薄势力吗?或许是因为之前我会`console.group`,而他们只会`console.log`时对我曾经崇拜过,谦卑过,所以,如今想要讨还回去吧。“苦涩地一笑,`xcharts`落寞地转身,安静地出了机房,孤单的身影与后方熙熙攘攘的人群如相隔两端。 23 | > 第一章后续自己脑补哈,就不水字数了。 24 | ## (二) cavnas大陆 25 | 夜色漆黑,只有显示器和RGB灯条发出了一些光亮。 26 | 27 | `xcharts`孤独地坐在电竞椅上,凝望着《计算机图形学》,想着这么久了还是看不懂,感到一阵沮丧,又想起下午他们的嘲讽,不禁有些恍惚:”十年了“。 28 | 29 | 在`xcharts`的心中,有一个仅有他自己知道的秘密,他并不是这个世界的人,或者说,他的灵魂不是这个世界的人,他来自一个叫地球的蔚蓝星球,是一个`php`程序员,他也不知道为什么会发生这样奇怪的事,反正不管怎么样,他来了,并且回不去了。 30 | 31 | 他穿越过来时变成了一个婴儿,还是被人遗弃的那种,是`Echarts`大人碰巧遇见将他带回来的。随着年龄的增长,对这个世界,也有了模糊的了解。 32 | 33 | 这个世界叫做`canvas`大陆,大陆上没有`php`,也没有`c`,虽然有`javascript`,但它只是为`canvas`服务的,`canvas`,才是大陆的唯一主调! 34 | 35 | 在这片大陆上,`canvas`的修炼,在无数代人的努力下,几乎已经发展到了巅峰的地步,而随着移动端浏览器的统一,几乎所有人都会学习`canvas`。不管什么应用,都能用`canvas`写出来。 36 | 37 | 随着`canvas`的发展,根据作用的不同,分出了好几条主要的分支,它们是: 38 | - 游戏引擎 39 | - 可视化 40 | - 其它应用 41 | 42 | 而所有分支又分为`3d`和`2d`。其下不知道有多少更细分的分支。`canvas`之力就是修习的成果。但是不管怎么样,`canvas`之力评判的标准是一样的,完成更多的功能,更酷炫的效果,更少的内存占用和更快运行效率。 43 | 44 | xcharts因为前世在培训班学php时用过一点js,所以初来时在js方面一枝独秀,但到使用`canvas`时,没有学过设计模式、数据结构与算法、计算机图形学的他只会根据api完成简单的功能,对如何实现复杂的应用根本无从下手——而这些东西,在其他人的脑海里与生俱来,他不敢让外人知道自己的与众不同。所以这些年来才会从天才堕落成废物! 45 | 46 | ”再过半个月就是年终考核了,届时`canvas`之力还没达到九段的话,就要被驱逐出去了,怎么办啊。“想起半个月后的考核,`xcharts`心中不由得一阵惶恐和难过。 47 | 48 | ”小朋友,别唉声叹气了“。 49 | 50 | > 好了,金手指出场了,我们去下一章。 51 | 52 | ## (三)XRender 53 | ”老师,您真的能让我的`canvas`之力在半个月后达到九段?“`xcharts`带着惊喜和疑问的语气看向眼前的老者。 54 | 55 | ”没错,只要你用心跟着我学习即可!“ 56 | 57 | ”好好好,那我们现在就开始吧!“ 58 | 59 | ”既然你这么着急,那我们现在就开始吧,你是走的可视化图表的路子,你可知道渲染器?“老者淡淡地问道。 60 | 61 | “我当然知道了。”这个`xcharts`还是知道的,“要想将数据变成图表,也就是可视化,除了需要将数据进行处理,变成方便使用的结构,还需要将这些数据渲染到`canvas`上成为可以看见的信息,这一步因为要考虑到多种环境,所以需要专门的渲染器来完成这份工作。” 62 | 63 | “不错,你可修炼出了渲染器?” 64 | 65 | “呃”,`xcharts`闻言有点羞愧,其他人这个年龄都已经初步修炼出了渲染器了,“想好了名字算吗?叫XRender” 66 | 67 | “你。。。”,听到这话,饶是老者脾气好,胡须也不禁一阵颤抖,“算了,这无关紧要,xrender是吧?即使它是个空壳,接下来半个月我也能教你如何将它打造成拥有九段`canvas`之力的渲染器!” 68 | 69 | “首先你要知道一个渲染器通常都是采用MVC模式的,也就是由数据驱动视图,至于C,我们先不管。” 70 | 71 | 接着渲染器通常由这几个部分构成: 72 | 73 | > 好了,今天的`canvas`破苍穹就到这里。戏说虽好,要真的学习还是要严肃一点。 74 | 75 | > 本系列只是趣味引入,真正的知识点请看[正文](https://github.com/webbillion/xrender-notes)。 76 | --------------------------------------------------------------------------------