作者:肖劍華
- 可視化是前端可視化
- 圖形是計算機圖形學
- 向量就是那個向量,高中學過的,你懂的
- 樹是那棵賊丑的樹
結果
首先先看看本文最終的結果。

是不是賊丑!是不是能在畫展上賣個好價格!
過程
好了,話不多說, 看看這棵賊丑的樹是怎么誕生的吧。
坐標系
坐標系,或者說平面直角坐標系,是幾何圖形學的基礎,其次是點、線、面這些元素。
坐標系大家都很熟悉, 最初接觸坐標系應該是初中, 那時候的坐標系不知大家還有沒有印象。
原點在中間, 水平軸是 x 軸, 豎軸是 y 軸, 分為四個象限。
但是呢, html canvas 這貨, 默認原點在左上角, x 軸是跟平面直角坐標系是一致的, y 軸是向下的??!
相信這種坐標軸在日常工作中使用 canvas 繪圖給前端人不知道造成過多少麻煩, 計算起來費事費力, 還容易出 bug。
那么如何把 canvas 的坐標系變成平面直角坐標系呢
Maaaaaaaaagic!
const canvas = document.querySelector('canvas')
const ctx = canvas.getContext('2d')
// 我們這里把原點定位在canvas左下角
ctx.translate(0, canvas.height)
// 關鍵步驟: 將canvasY軸方向翻轉
ctx.scale(1, -1)
兩行代碼, 就完成了對坐標系的翻轉。
我們用一個 ?? 來驗證一下
假設,我們要在寬 512 * 高 256 的一個 Canvas 畫布上實現(xiàn)如下的視覺效果。其中,山的高度是 100,底邊 200,兩座山的中心位置到中線的距離都是 80,太陽的圓心高度是 150。
我們這里使用 rough.js 增加一下趣味性
<canvas
width="512"
height="256"
style="display: block;margin: 0 auto;background-color: #ccc"
></canvas>
const canvas = document.querySelector('canvas')
const rc = rough.canvas(canvas)
rc.ctx.translate(0, canvas.height)
rc.ctx.scale(1, -1)
const cSun = [canvas.width / 2, 106]
const diameter = 100 // 直徑
const hill1Points = {
start: [76, 0], // 起始點
top: [176, 100], // 頂點
end: [276, 0] // 終點
}
const hill2Points = {
start: [236, 0], // 起始點
top: [336, 100], // 頂點
end: [436, 0] // 終點
}
const hill1Options = {
roughness: 0.8,
stokeWidth: 2,
fill: 'pink'
}
const hill2Options = {
roughness: 0.8,
stokeWidth: 2,
fill: 'chocolate'
}
function createHillPath(point) {
const { start, top, end } = point
return `M${start[0]} ${start[1]}L${top[0]} ${top[1]}L${end[0]} ${end[1]}`
}
function paint() {
rc.path(createHillPath(hill1Points), hill1Options)
rc.path(createHillPath(hill2Points), hill2Options)
rc.circle(cSun[0], cSun[1], diameter, {
stroke: 'red',
strokeWidth: 4,
fill: 'rgba(255, 255, 0, 0.4)',
fillStyle: 'solid'
})
}
paint()
這里我們翻轉了坐標系, 定義了 mountain1,mountain2,太陽 的各個點的坐標, 完全是參照直角坐標系的坐標。
最終的實現(xiàn)效果如下

(是不是也能在畫展上賣個不錯的價格)
向量
定義
說完直角坐標系的轉換, 我們來討論今天的正主, 向量(Vector)
向量的普遍定義是具有大小和方向的量, 我們這里討論的向量是 幾何向量, 是用一組平面直角坐標系的坐標表示的
例如 (1, 1), 意思是, 頂點坐標為 x 為 1,y 為 0 的一條有向線段, 向量的方向是由 原點(0, 0) 指向頂點(1,1)的方向。
換言之, 知道了向量的頂點, 就知道了向量的大小和方向
向量的模
向量的大小也叫向量的模,是向量坐標的平方和的算術平方根, length = Math.pow((x2 + y2), 0.5)。
向量的方向
向量的方向一方面可以使用向量的頂點表示。
另外一方面使用向量和 x 軸的夾角,也能夠表示一個向量。
使用 javascript Math 的內置方法可以得到,計算方式:
// 構造函數(shù)在本文稍后的地方介紹
const v = new Vector2D(1, 10)
const dir = Math.atan2(v.y, v.x)
四則運算
加減法
示意圖:

如圖所示: 向量 v1(x1, y1)和向量 v2(x2, y2)相加得到的新的向量就是兩個向量對應坐標之和, 用公式表達就是
v1(x1, y1) + v2(x2, y2) = v3(x1 + x2, y1 + y2)
反之就是減法 v3(x1 + x2, y1 + y2) - v2 (x2, y2)= v1(x1, y1)
乘除
向量乘法有 叉乘和點乘
點乘示意圖:

物理意義是, 方向為 va 方向,大小為 va.length 的力, 沿 vb 方向拉動 vb.length 距離所做的功
va * vb = va.length * vb.length * cos(rad)
叉乘示意圖:

va * vb = va.length * va.length * sin(rad)
也可以理解為長度為 va.length 的線段沿著 vb 方向移動到 vb 頂點掃過的面積, 反之就是除法
乘除這里僅做概念上的介紹
單位向量
長度為 1 的向量叫做單位向量, 滿足這個條件的向量有無數(shù)條, 一個非 0 的向量除以他的模,就是這個向量的單位向量, 我們取與 x 軸夾角為 0 的向量:[1, 0]作為單位向量
向量的旋轉
將一個向量轉動一定的角度 rad 之后的向量該如何計算呢。
這里有比較復雜的推導過程, 因此可以直接記住結論。
具體代碼在下面構造函數(shù)里面展示
構造器
// 用一個長度為2的數(shù)組表示一個向量, 下標為0的位置表示x 下標為1的位置表示 y
class Vector2D extends Array {
constructor(x = 1, y = 0) {
super(x, y)
}
get x() {
return this[0]
}
get y() {
return this[1]
}
set x(v) {
this[0] = v
}
set y(v) {
this[1] = v
}
add(v) {
this.x = this.x + v.x
this.y = this.y + v.y
return this
}
length() {
return Math.hypot(this.x, this.y)
}
rotate(rad) {
const c = Math.cos(rad)
const s = Math.sin(rad)
const [x, y] = this
this.x = x * c + y * -s
this.y = x * s + y * c
return this
}
}
至此,畫出文章開頭的那個圖形的基本要素都已經(jīng)準備好了。
下面, 讓我們來見證一下世界名畫的產(chǎn)生。
動手畫圖
- 準備一個 512 * 512 的畫布
<html>
...
<canvas
width="512"
height="512"
style="display:block;margin:0 auto;background-color: #ccc"
></canvas>
...
</html>
- 翻轉 canvas 坐標系
const canvas = document.querySelector('canvas')
const ctx = canvas.getContext('2d')
ctx.translate(0, canvas.height)
ctx.scale(1, -1)
- 定義繪制樹枝的方法
/**
* 1. ctx canvas ctx 上下文對象
* 2. 起始向量
* 3. length 向量長度(樹枝長度)
* 4. thickness 線段寬度
* 5. 單位向量 dir 旋轉角度
* 6. bias 隨機因子
*/
const canvas = document.querySelector('canvas')
const ctx = canvas.getContext('2d')
ctx.translate(0, canvas.height)
ctx.scale(1, -1)
ctx.lineCap = 'round'
console.log(canvas.width)
const v0 = new Vector2D(canvas.width / 2, 0)
function drawBranch(ctx, v0, length, thickness, rad, bias) {
const v = new Vector2D().rotate(rad).scale(length)
console.log(v, rad, length)
const v1 = v0.copy().add(v)
ctx.beginPath()
ctx.lineWidth = thickness
ctx.moveTo(...v0)
ctx.lineTo(...v1)
ctx.stroke()
ctx.closePath()
}
// 定義好了之后我們先畫一個樹枝試試看
drawBranch(ctx, v0, 50, 10, Math.PI / 2, 1)
- 遞歸畫圖
// 先定義收縮系數(shù)
const LENGTH_SHRINK = 0.9
const THICKNESS_SHRINK = 0.8
const RAD_SHRINK = 0.5
const BIAS_SHRINK = 1
function drawBranch(ctx, v0, length, thickness, rad, bias) {
// ....
if (thickness > 2) {
// 畫左樹枝
const left =
Math.PI / 4 +
RAD_SHRINK * (rad + 0.2) +
drawBranch(
ctx,
v1,
length * LENGTH_SHRINK,
thickness * THICKNESS_SHRINK,
left,
bias
)
// 畫右樹枝
const right = Math.PI / 4 + RAD_SHRINK * (rad - 0.2)
drawBranch(
ctx,
v1,
length * LENGTH_SHRINK,
thickness * THICKNESS_SHRINK,
right,
bias
)
}
}
drawBranch(ctx, v0, 50, 10, Math.PI / 2, 1)
這一步畫出來的是一個比較規(guī)則的形狀, 代碼寫到這一步,樹的基本形狀已經(jīng)出來了,但是 為了展示效果, 向量翻轉上加一些隨機性來畫一顆更加接近自然狀態(tài)的樹。代碼如下:
function drawBranch(ctx, v0, length, thickness, rad, bias) {
// ....
if (thickness > 2) {
// 畫左樹枝
const left =
Math.PI / 4 + RAD_SHRINK * (rad + 0.2) + bias * (Math.random() - 0.5) // 加些隨機數(shù)
drawBranch(
ctx,
v1,
length * LENGTH_SHRINK,
thickness * THICKNESS_SHRINK,
left,
bias
)
// 畫右樹枝
const right =
Math.PI / 4 + RAD_SHRINK * (rad - 0.2) + bias * (Math.random() - 0.5) // 加些隨機數(shù)
drawBranch(
ctx,
v1,
length * LENGTH_SHRINK,
thickness * THICKNESS_SHRINK,
right,
bias
)
}
}
drawBranch(ctx, v0, 50, 10, Math.PI / 2, 1)
等等等等, 效果圖:一棵光禿禿的樹

(是不是有點藝術內味兒了)
剩下的就是添加一些點綴, 把果子掛上
function drawBranch(ctx, v0, length, thickness, rad, bias) {
// .....
if (thickness < 5 && Math.random() < 0.3) {
const th = 6 + Math.random()
ctx.save()
ctx.strokeStyle = '#e4393c'
ctx.lineWidth = th
ctx.beginPath()
ctx.moveTo(...v1)
ctx.lineTo(v1.x, v1.y + 2)
ctx.stroke()
ctx.closePath()
ctx.restore()
}
}
drawBranch(ctx, v0, 50, 10, Math.PI / 2, 3) // 這里增大了隨機因子, 讓樹枝更加分散
此時效果圖就出來了:

(我再問一遍, 是不是很好看, 是不是很想花個幾百萬小錢買下它)
對于drawBranch第一調用, 可以嘗試調一調參數(shù),看看結果如何。
完整代碼地址:github
總結
本文首先展示了如何將 canvas 的坐標系轉化為直角坐標系
其次用一個例子演示了,向量在圖形學內的基本運算。
向量運算的意義并不僅僅只是用來算點的位置和構造線段,這只是最初級的用法。
可視化呈現(xiàn)依賴于計算機圖形學,而向量運算是整個計算機圖形學的數(shù)學基礎。而且,在向量運算中,除了加法表示移動點和繪制線段外,向量的點乘、叉乘運算也有特殊的意義。
我們是曉黑板前端,歡迎關注我們的知乎、Segmentfault、CSDN、簡書、開源中國賬號。