注意:以上的demo還有一些bug,沒時間修復,另外預覽地址是直接在github上部署的,所以最好通過vpn科學上網,否則可能訪問不了。還有,上面的股票詳情頁,還沒有做自適應,等我有時間再改。
用屏幕取色器,獲取東方財富的配色 codeinword.com/eyedropper
圖一、圖二,是參考東方財富黑白皮膚的配色,圖三是參考騰訊自選股的配色。
canvas在畫線段,通常會出現以下代碼:
cxt.moveTo(x1, y1);
cxt.lineTo(x2, y2);
cxt.lineWidth = 1;
cxt.stroke();
假設上面的兩個點是(1,10)和(5,10),那麼畫出來的實際上是一條橫線,理論上橫線的粗度是1px,且該橫線被y=10切成兩半,上半部分粗度是0.5px,下半部分粗度也是0.5px,這樣橫線的整體粗度才會是1px。
但是canvas 不是這樣處理的,canvas默認線條會與整數對齊,也就是橫線的上部分不會是y=9.5px,而是y=9px;橫線的下半部分也不是y=10.5px,而是y=11px;從而橫線的粗度看起來不是1px,而是2px。
並且由於粗度被拉伸,顏色也會被淡化,那怎麼解決這個問題呢?
處理方式也很簡單,通過cxt.translate(0.5, 0.5)
將坐標往下移動0.5個像素,然後接下來的所有點,都保證是整數即可,這樣就能保證不會被拉伸。
典型的代碼如下:
cxt.translate(0.5, 0.5);
cxt.moveTo(Math.floor(x1), Math.floor(y1));
cxt.lineTo(Math.floor(x2), Math.floor(y2));
cxt.lineWidth = 1;
cxt.stroke();
在我的代碼中,也體現了類似的處理。
設備像素比越高,理論上應該越清晰,因為原來用一個小方塊來渲染1px,現在用2個小方塊來渲染,應該更清晰才對,但是canvas 不是這樣的。
例如,通過js獲取父容器div的寬度是width,這時如果設置canvas.width = width
,在設備像素比為2的時候,canvas畫出來的寬度為css對應寬度的一半,如果強制通過css將canvas寬度設置為width,則canvas會被拉長一倍,導致出現齒狀模糊。
注意了嗎?上面所說的canvas.width=width
與css設置的#canvas { width: width }
起到的效果是不一樣的。不要隨便通過css去設置canvas的寬高,容易被拉伸變形或者導致模糊。
通用的處理方式是:
//初始化高清Canvas
function initHDCanvas() {
const rect = hdCanvas.getBoundingClientRect();
//設置Canvas內部尺寸為顯示尺寸乘以設備像素比
const dpr = window.devicePixelRatio || 1;
hdCanvas.width = rect.width * dpr;
hdCanvas.height = rect.height * dpr;
//設置Canvas顯示尺寸保持不變
hdCanvas.style.width = rect.width + 'px';
hdCanvas.style.height = rect.height + 'px';
//獲取上下文並縮放
const ctx = hdCanvas.getContext('2d');
ctx.scale(dpr, dpr);
}
為了方便樣式自定義,我獨立出一個默認的配置對象defaultKlineConfig
,參數的含義如下圖所示,其實下圖這個風格的標註,是通過 excalidraw 這個軟件畫的,也是canvas做的開源軟件,可見canvas在前端可視化領域的重要性,這個扯遠了,打住。
如上圖,整個canvas畫板,分成5部分,每一部分的高度,都可以設置,其中主圖和副圖的高度,是通過比例來計算的:mainChartHeight = restHeight * mainChartHeightPercent
,其中,restHeigh
是畫板總高度height減去其他幾部分計算的,如下:restHeight = height - lineMessageHeight - tradeMessageHeight - xLabelHeight
十字交叉線的顏色,X軸與Y軸的tooltip背景色、字體大小的參數如下:
從上面的圖可以看出,需要畫5日均線、10日均線、20日均線,成交量快線(10日)、成交量慢線(20日)但是,接口沒有給出當日的均線值,需要自己計算。
5日均線 = (過去4個成交日的收盤價總和 + 今日收盤價)/ 5
10日均線 = (過去9個成交日的收盤價總和 + 今日收盤價)/ 10
20日均線 = (過去19個成交日的收盤價總和 + 今日收盤價)/ 20
成交量快線 = (過去9日成交量 + 今日成交量)/ 10
成交量慢線 = (過去19日成交量 + 今日成交量)/ 20
所以,當獲取lmt(一屏的蠟燭圖個數)個數據時,為了計算均線,需要至少將前19個(我的代碼寫20)數據都獲取到。當一個均線已經獲取到,下一個均線就不需要再累加20個值再得平均數,可以省一點計算:
*今日20日均線值 = (昨日均線值 20 - 前面第20個的收盤價 + 今日收盤價)/ 20;**
為了減少重繪,提高性能,可以將K線圖做分層渲染。那分幾層合適?我認為是三層。
首先,網格是固定的,也就是說,當頁面拖拽、或者長按出現十字交叉的時候,底部的網格線是不變的,如果每次拖拽,都需要重繪網格,那這個其實是沒有必要的開銷,可以將網格放在最底層,一次性繪製後,就不要再重繪。
由於拖拽的時候,蠟燭柱體、均線、Y軸刻度,X軸刻度,都需要重繪,這一塊是無法改變的事實,所以,變動層放在中間層,也是最繁忙的一層,並且該層不響應觸摸事件,觸摸事件交給交互層。
交互層監聽觸摸事件:當頁面快速滑動,則響應拖拽事件,即K線圖的時間線會左右滑動;當用戶長按之後再滑動,則出現十字交叉浮層。
交互層的好處是,當響應十字交叉浮層時,只需要繪製橫線、豎線、對應X軸和Y軸的值,而不需要重繪蠟燭柱體和均線,可以減少重繪,最大程度減少渲染壓力。
首先計算出主圖的高度this.mainChartHeight
,將主圖從上到下等分為4部分,再在左右兩邊畫出豎線,形成主圖的網格,副圖是成交量圖,只需畫一個矩形邊框即可,用strokeRect
即可畫出。
private drawGridLine() {
//獲取配置參數
const { gridColor, lineMessageHeight, xLabelHeight, width, height } = this.config;
//畫出K線圖的5條橫線
const split = this.mainChartHeight / 4;
this.canvasCxt.beginPath();
this.canvasCxt.lineWidth = 0.5;
this.canvasCxt.strokeStyle = gridColor;
for (let i = 0; i <= 4; i++) {
const splitHeight = Math.floor(split * i) + lineMessageHeight!;
this.drawLine(0, splitHeight, width, splitHeight);
}
//畫出K線圖的2條豎線
this.drawLine(0, lineMessageHeight!, 0, lineMessageHeight! + this.mainChartHeight);
this.drawLine(width, lineMessageHeight!, width, lineMessageHeight! + this.mainChartHeight);
//畫出成交量的矩形
this.canvasCxt.strokeRect(
0,
height - xLabelHeight! - this.subChartHeight,
width,
this.subChartHeight,
);
}
首先計算出一屏的股價最大值max
,股價最小值min
,成交量最大值maxAmount
。
當某一個點的均線為value
,根據最大值、最小值、索引index
,計算出坐標點(x, y),畫均線的時候,第一個點用moveTo(x0, y0)
,其他點用lineTo(xn yn)
,最後stroke
連起來即可。
當然,每一條線設置下顏色,即strokeStyle
。
//畫出各類均線
private drawLines(max: number, min: number, maxAmount: number) {
//將寬度分成n個小區間,一個小區間畫一個蠟燭,每個區間的寬度是splitW
const splitW = this.config.width / this.config.lmt!;
//畫一下5日均線
this.canvasCxt.beginPath();
this.canvasCxt.strokeStyle = this.config.ma5Color;
this.canvasCxt.lineWidth = 1;
let isTheFirstItem = true;
for (
let i = this.startIndex;
i < this.arrayList.length && i < this.startIndex + this.config.lmt!;
i++
) {
const index = i - this.startIndex;
let value = this.arrayList[i].ju5;
if (value === 0) {
continue;
}
const x = Math.floor(index * splitW + 0.5 * splitW);
const y = Math.floor(
((max - value) / (max - min)) * this.mainChartHeight + this.config.lineMessageHeight!,
);
if (isTheFirstItem) {
this.canvasCxt.moveTo(x, y);
isTheFirstItem = false;
} else {
this.canvasCxt.lineTo(x, y);
}
}
this.canvasCxt.stroke();
}
當收盤價大於等於開盤價,選用上面左邊紅色的樣式;當收盤價小於開盤價,選用上面右邊綠色的樣式。
以紅色蠟燭為例,最高點A(x0, y0),最低點是B(x1, y1),高度height、寬度width都是相對於坐標軸的,紅色矩形左上角的頂點是D(x, y)。
為了畫出紅色蠟燭,先後順序別搞混:
moveTo
,lineTo
畫出來;cxt.rect(x, y, width, height)
;fill
填充白色背景,同時覆蓋後面的紅色豎線;stroke
描出紅色邊框。按照上面這個順序,豎線會被覆蓋掉,同時,白色填充不會擠壓紅色邊框,如果先stroke
再fill
,容易出現白色填充覆蓋紅色邊框,可能會變模糊,或者使得紅色變淡,相當不友好,所以按照我上面的順序,可以減少不必要的麻煩。
canvas畫出文字,典型的代碼如下
this.canvasCxt.beginPath();
this.canvasCxt.font = `${this.config.yLabelFontSize}px "Segoe UI", Arial, sans-serif`;
this.canvasCxt.textBaseline = 'alphabetic';
this.canvasCxt.fillStyle = this.config.yLabelColor;
注意textBaseline
默認對齊方式是alphabetic,但middle往往更好用,能實現垂直居中,但我發現垂直居中也不是很居中,所以會特意加減1、2個像素;
當然還有個textAlign
,能實現水平對齊方式,左右對齊都可以,例如上圖最左、最右的時間標籤。
根據上面的GIF動圖,可以知道,本次做的移動端K線圖,最重要的兩個交互是:
上面的交互,其實是比較複雜的,所以需要先設計一個簡單的數據結構:
arrayList
startIndex
,表示當前螢幕從startIndex
開始畫蠟燭圖當用戶往右快速拖拽時,startIndex
根據用戶拖拽的距離,適當變小;當用戶往左快速拖拽時,startIndex
根據用戶拖拽的距離,適當變大。
那arrayList
到底多長合適,因為股票可能有十幾年的數據,甚至上百年的數據,我不能一次性拉取這個股票的所有數據吧?
當然,站在軟體性能、消耗等角度,也不應該一次性拉取所有的數據,我的答案是arraylist
最多保存5屏的數據量,用戶看到的螢幕,應該是接近中間這一屏,也就是第3屏的數據,左右兩邊各保存2屏數據,這樣,用戶拖拽的時候,可以比較流暢,而不是每次拖拽都要等拉取數據再去渲染。
那什麼時候拉取新的數據呢?
用戶觸摸完後,當startIndex
左邊的數據少於2屏,開始拉取左邊的數據;
用戶觸摸完後,當startIndex
右邊的數據少於2屏,開始拉取右邊的數據;
那如果用戶一直往右拖拽,是不是就一直往左邊添加數據,這個arraylist
是不是會變得很長?
當然不是,例如,當我往arraylist
的左邊添加數據的時候,startIndex
也會跟著變動,因為用戶看到的第一條柱體,在arraylist
的索引已經變了。當我往arraylist
的某一邊添加數據後,arraylist
的另一邊如果數據超過2屏,要適當裁掉一些數據,這樣arraylist
的總數,始終保持在5屏左右,就不會佔用太多的存放空間。
總體思想是,從startIndex
開始繪製螢幕的第一條柱體,當前螢幕的左右兩邊,都預留2屏數據,防止用戶拖拽出發頻繁拉取數據,導致卡頓;同時也控制了arraylist
的長度,這是虛擬列表的變形,這樣設計,可以做一個高性能的K線圖。
根據上面的分析:
以上兩種拖拽,都在touchmove
事件中觸發,那怎麼區分開呢?
典型的touchstart
、touchmove
、touchend
解耦如下:
let timer = null;
let startX = 0;
let startY = 0;
let isLongPress = false;
canvas.addEventListener('touchstart', (e) => {
startX = e.touches[0].clientX;
startY = e.touches[0].clientY;
isLongPress = false;
timer = setTimeout(() => {
isLongPress = true;
// 顯示十字光標hover
showCrossHair(e);
}, 500);
});
canvas.addEventListener('touchmove', (e) => {
if (isLongPress) {
// 長按移動時更新十字光標位置
updateCrossHair(e);
} else {
// 快按拖動時移動K線圖
clearTimeout(timer);
moveKLineChart(e);
}
});
canvas.addEventListener('touchend', () => {
clearTimeout(timer);
if (isLongPress) {
// 長按結束隱藏十字光標
hideCrossHair();
}
isLongPress = false;
});
// 关闭十字光标
function hideCrossHair() {
// 隱藏邏輯
}
根據上面的框架,再詳細補充下代碼就可以了。
然後再在touchend
事件中,新增或減少arraylist
的數據量。
其實,做到上面的設計,性能已經很好了,可以監控幀率來看下滑動的流暢程度。
總結下為什麼高性能:
將K線圖畫在3個canvas上。
當需要在K線上標註一些icon時,這些icon可以先離屏渲染,需要的時候,再copy到變動層對應的位置,這樣比臨時抱佛腳去畫,要省很多時間,也能提高性能。
就是螢幕只渲染一屏數據,但在當前屏的左右兩邊,各緩存了2屏數據,超過5屏數據的時候,及時裁掉多餘的數據,這樣arraylist
的數據量始終保持在5屏,有效的控制了佔用空間。
touchmove
會很頻繁觸發,可通過節流來控制,減少不必要的渲染。
npm install --save-dev gh-pages
注意,Stock這個需要對應github的倉庫名
{
"homepage": "https://fhrddx.github.io/Stock",
"scripts": {
"predeploy": "npm run build",
"deploy": "gh-pages -d build"
}
}
npm run build
npm run deploy
最後,訪問上面的鏈接(注意,在國內可能要開vpn)
這樣,github pages部署成功,訪問上面鏈接,可以看到如下效果。