在 Chart.js 的 Bar Chart 中加入箭頭標註

Eason Lin
12 min readNov 6, 2021

--

最近在規劃新需求上因為需要呈現圖表讓使用者得知資訊,引入了 Chart.js 3.x,其中一個需求為「須根據使用者的落點予以指標提示」(如下圖)。

原先在此功能卡了一陣子,從翻 Awesome Chart.js 到最後決定自己寫,這篇文大致記錄一下最後如何實現了此需求。

如何讓 Chart.js 渲染圖表

Chart.js 是一個基於 Cavnas 的 JS 函式庫,僅需帶入資料及設定就能繪製出漂亮的圖表,例如參考官網的範例

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<canvas id="myChart" width="400" height="400"></canvas>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
const ctx = document.getElementById("myChart").getContext("2d");
const myChart = new Chart(ctx, {
type: "bar",
data: {
labels: ["Red", "Blue", "Yellow", "Green", "Purple", "Orange"],
datasets: [
{
label: "# of Votes",
data: [12, 19, 3, 5, 2, 3],
backgroundColor: [
"rgba(255, 99, 132, 0.2)",
"rgba(54, 162, 235, 0.2)",
"rgba(255, 206, 86, 0.2)",
"rgba(75, 192, 192, 0.2)",
"rgba(153, 102, 255, 0.2)",
"rgba(255, 159, 64, 0.2)",
],
borderColor: [
"rgba(255, 99, 132, 1)",
"rgba(54, 162, 235, 1)",
"rgba(255, 206, 86, 1)",
"rgba(75, 192, 192, 1)",
"rgba(153, 102, 255, 1)",
"rgba(255, 159, 64, 1)",
],
borderWidth: 1,
},
],
},
options: {
scales: {
y: {
beginAtZero: true,
},
},
},
});
</script>
</body>
</html>

首先可以看到:

const ctx = document.getElementById("myChart").getContext("2d");
const myChart = new Chart(ctx, { // ...

這邊範例的 ctx 其實就是 Canvas 產生的畫布中的渲染環境(rendering context),若要透過 JS 在 Canvas 上進行操作,起手式都是取得其渲染環境,例如在 MDN 關於 Canvas 的教學,最開始的範例程式碼就是:

var canvas = document.getElementById('tutorial');
var ctx = canvas.getContext('2d');

接著把 ctx 放入 new Chart 的第一個參數,告訴 Chart.js 我們要使用這個畫布來渲染圖表,後面則是依照自身所需提供一個 JS 物件去告訴 Chart.js 該採取怎樣的渲染策略。

知道了 ctx 為渲染環境,我們就能揣測「可以直接在 Chart.js 渲染的圖表中加入其他圖形、文字或圖片」。

了解 Chart.js 的 Plugins

若要自定義一些行為,最適合切入的點會是使用 Plugins。我們很快地看過 Plugins 的範例程式碼:

const chart = new Chart(ctx, {
plugins: [{
beforeInit: function(chart, args, options) {
//..
}
}]
});

看到這裡我當時的直覺是:「這不就像是 Vue 的生命週期鉤子嗎?」事實上概念也非常類似,Chart.js 在渲染時會經過非常多階段,每個階段都能傳入函式告訴其在完成這個階段後要做什麼事情,Chart.js 在呼叫其函式時也會傳入部分參數供其更便利地操作圖表。其大略經過:

  • 初始化階段
  • 更新階段
  • 渲染階段

在這個例子中,我們僅須關注渲染階段:

https://www.chartjs.org/docs/latest/developers/plugins.html#chart-initialization

從官網的流程圖中,可以看出渲染階段大致經過這些過程:

beforeRender→beforeDraw→beforeDatasetsDraw→beforeDatasetDraw→afterDatasetDraw→afterDatasetsDraw→afterDraw→afterRender

在此我們採用 afterDatasetsDraw 在資料繪製完成的階段繪製我們的箭頭圖片,在此範例,我使用了 vectorhq 提供的紅色箭頭。要在 Canvas 上繪製圖片,我們可以使用 CanvasRenderingContext2D.drawImage,先簡單測試是否有效:

const ctx = document.getElementById("myChart").getContext("2d");
const getArrowImage = () => {
const img = new Image();
img.src = "arrow-down.png";
return img;
};
const arrowImage = getArrowImage();
const myPlugin = {
afterDatasetsDraw() {
ctx.drawImage(arrowImage, 0, 0, 32, 32);
},
};
const myChart = new Chart(ctx, {
type: "bar",
plugins: [myPlugin],
// ...

在此範例中,我們使用 ctx.drawImage 將 arrowImage 以 32x32 的寬高繪製在畫布座標 0, 0 的位置

看起來成功了,最後一步就是知道長條圖的座標位置。在 afterDatasetsDraw 函式中可以傳入 chart 作為參數,若把它 log 出來,並仔細找一下,可以發現 _metasets 屬性中的 data 包含了每個長條的座標:

接著我們試著將座標寫入 drawImage 的 dx 及 dy 參數:

const myPlugin = {
afterDatasetsDraw(chart) {
const drawingBar = chart._metasets[0].data[0];
const { x: dx, y: dy } = drawingBar;
const imageWidth = 32;
const imageHeight = 32;
ctx.drawImage(
arrowImage,
dx - imageWidth / 2,
dy - imageHeight,
imageWidth,
imageHeight
);
},
};
大功告成!

如果需要讓箭頭往上一點,則是調整 dy 的值;若箭頭指向最高的長條,導致箭頭超出畫布範圍,則可以調整 options.scales.y.max,完整程式碼可參考:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<canvas id="myChart" width="400" height="400"></canvas>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
const ctx = document.getElementById("myChart").getContext("2d");
const getArrowImage = () => {
const img = new Image();
img.src = "arrow-down.png";
return img;
};
const arrowImage = getArrowImage();
const myPlugin = {
afterDatasetsDraw(chart) {
const drawingChart = chart._metasets[0].data[1];
const { x: dx, y: dy } = drawingChart;
const imageWidth = 32;
const imageHeight = 32;
ctx.drawImage(
arrowImage,
dx - imageWidth / 2,
dy - imageHeight - 50,
imageWidth,
imageHeight
);
},
};
const myChart = new Chart(ctx, {
type: "bar",
plugins: [myPlugin],
data: {
labels: ["Red", "Blue", "Yellow", "Green", "Purple", "Orange"],
datasets: [
{
label: "# of Votes",
data: [12, 19, 3, 5, 2, 3],
backgroundColor: [
"rgba(255, 99, 132, 0.2)",
"rgba(54, 162, 235, 0.2)",
"rgba(255, 206, 86, 0.2)",
"rgba(75, 192, 192, 0.2)",
"rgba(153, 102, 255, 0.2)",
"rgba(255, 159, 64, 0.2)",
],
borderColor: [
"rgba(255, 99, 132, 1)",
"rgba(54, 162, 235, 1)",
"rgba(255, 206, 86, 1)",
"rgba(75, 192, 192, 1)",
"rgba(153, 102, 255, 1)",
"rgba(255, 159, 64, 1)",
],
borderWidth: 1,
},
],
},
options: {
scales: {
y: {
beginAtZero: true,
max: 24,
},
},
},
});
</script>
</body>
</html>

結語

其實 Awesome Chart.js 中已經有提供了類似的插件,但礙於客製化程度仍不夠高,網路上對於這樣的需求討論也大多傾向於提供該插件參考,最後是邊看該插件官網的文件,邊翻他的源碼邊把一些關鍵字丟進 Chart.js 官方文件才慢慢找到作法。

不過靠翻源碼找到解答這樣的事情對我而言還是第一次發生,覺得蠻新鮮也很開心自己似乎又變強了一點。

這篇文就記錄到這,如果內文有任何錯誤,也煩請不吝指出,希望能幫助到被這個需求困住的人。

References:

--

--

Eason Lin
Eason Lin

Written by Eason Lin

Frontend Web Developer | Books

No responses yet