node.jsでchart.jsを使う

expressを使ったWebAPIでグラフの画像を生成したかったので、Javascsriptで有名なchart.jsをNode.jsで動かすnpmモジュール「chartjs-node-canvas」を使いこなします。

以下の点を意識しています。

・Node.jsでchart.jsを動かす場合も日本語がちゃんと表示されるようにする
・chart.jsの凡例やデータの指定方法がグラフの種類によってまちまちなのを統一する
・背景に色を塗ったり、前景全体に文字列を重ね合わせられるようにする

上記をクラス化して、使いやすい形にしていきます。

ソースコードもろもろは、GitHubに上げておきました。

poruruba/MakeChart_test

https://github.com/poruruba/MakeChart_test

※node-fetchは、v2系を使った方がよいかも。

```
> npm install node-fetch@2.6.5
```

＜参考URL＞
・https://www.chartjs.org/docs/latest/
・https://github.com/SeanSobey/ChartjsNodeCanvas/blob/master/API.new.md
・https://github.com/SeanSobey/ChartjsNodeCanvas
・https://github.com/shrhdk/text-to-svg

#chartjs-node-canvasのインストール

基本的には以下で説明の通りに実施します。

SeanSobey/ChartjsNodeCanvas

https://github.com/SeanSobey/ChartjsNodeCanvas

```
> npm install chartjs-node-canvas chart.js@2.9.4
```

chart.jsは、v2系の情報が世の中に多いので、2系の最新バージョンにしています。

あとは、説明の通りに進めればよいのですが、このままでは日本が表示されません。
タイトルや凡例、ラベルなどで使われます。そこで、日本語フォントを登録します。

IPAフォントを使わせていただきました。

https://moji.or.jp/ipafont/ipaex00401/

IPAexゴシック：ipaexg00401.zipをダウンロードし、適当な場所に解凍します。
解凍する出てく308Bipaexg.ttfというファイルを使います。

```js:api\controllers\makechart-api\genchart.js
const FONT_PATH = '【フォントを配置したフォルダ】' + '/ipaexg.ttf';
const FONT_NAME = "IPAEXG";
const FONT_COLOR = "#333333aa";

const { ChartJSNodeCanvas } = require('chartjs-node-canvas');

const chartJSNodeCanvas = new ChartJSNodeCanvas({
  width: width,
  height: height,
  chartCallback: (ChartJS) => {
    ChartJS.defaults.global.defaultFontFamily = FONT_NAME;
    ChartJS.defaults.global.defaultFontColor = FONT_COLOR;
  }
});
chartJSNodeCanvas.registerFont(FONT_PATH, { family: FONT_NAME });
```

widthとheightには、生成したいグラフ画像のサイズを指定します。
FONT_COLORは、文字の色および透過率を指定しています。お好みで変更してください。

#グラフのテンプレートを作る

あまり深いことを気にせずに作れるように、6種類ほどのグラフをテンプレート化しておきます。
そうすることで、たくさんあるchart.jsのオプション群を毎回思い出す必要がなくなるのと、最初に述べた通り、凡例とデータの並びに統一性を持たせたいためです。

以下6種類のテンプレートを作ります。

|名称|種類|
|---|---|
|doughnut|タコメータのようなもの|
|gauge|横棒ゲージのようなもの|
|line|折れ線グラフ|
|pie|円グラフ|
|stackbar|積み上げ棒グラフ|
|bar|棒グラフ|

以降は、テンプレート化後のパラメータ指定方法を示しますが、chart.jsの指定への変換内容は、今回作成したクラスのソースファイルをご参照ください。

##タコメータのようなもの

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/a46b6c4e-6b83-e62e-8675-5125d3ddf82a.png)

|必須|名前1|名前2|内容|備考|
|---|---|---|---|---|
|〇|value||値||
||legend||凡例|指定がない場合表示されない|
||title||タイトル|指定がない場合表示されない|
|〇|range|max|最大値|100%となる値|

```json:例
{
	"value": 10,
	"legend": "凡例1",
	"title": "チャートタイトル",			
	"range": { "max": 50 }
}
```

##横棒ゲージのようなもの

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/b7a2abb3-baae-42e2-2833-9b950ad96466.png)

|必須|名前1|名前2|内容|備考|
|---|---|---|---|---|
|〇|value||値||
||legend||凡例|指定がない場合表示されない|
||title||タイトル|指定がない場合表示されない|
|〇|range|min|最小値|0%となる値|
|〇||max|最大値|100%となる値|

```json:例
{
	"value": 10,
	"legend": "凡例1",
	"title": "チャートタイトル",			
	"range": { “min”: 0, "max": 50 }
}
```

##折れ線グラフ

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/a9f988ce-4fcb-25ad-718b-c860c9df5246.png)

|必須|名前1|名前2|内容|備考|
|---|---|---|---|---|
|〇|datum||値の2次元配列||
|〇|labels||ラベルの配列||
||legends||凡例の配列|指定がない場合表示されない|
||title||タイトル|指定がない場合表示されない|
||range|min|最小値|Y軸の最小値|
|||max|最大値|Y軸の最大値|

```json:例
{
	"datum": [
		[880, 740, 900, 520, 930],
		[380, 440, 500, 220, 630]
	],
	"labels": ["1月", "2月", "3月", "4月", "5月"],
	"legends": ["プリンター販売台数", "パソコン販売台数"],
	"range": { "min": 0, "max": 1000 },
	"title": "チャートタイトル"
}
```

##円グラフ

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/04ab2369-d106-fe1b-84d9-e051614681b5.png)

|必須|名前1|名前2|内容|備考|
|---|---|---|---|---|
|〇|datum||値の配列||
||legends||凡例の配列|指定がない場合表示されない|
||title||タイトル|指定がない場合表示されない|

```json:例
{
	"datum": [880, 740, 100],
	"legends": ["OK", "NG", "UNKNOWN"],
	"title": "チャートタイトル"
}
```

##積み上げ棒グラフ

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/ed1db928-730a-a741-14ae-e4f843078e03.png)

|必須|名前1|名前2|内容|備考|
|---|---|---|---|---|
|〇|datum||値の2次元配列||
|〇|labels||ラベルの配列| |
|〇|legends||凡例の配列| |
||title||タイトル|指定がない場合表示されない|

```json:例
{
	"datum": [[1, 4], [5, 0], [3, 2], [4, 1]],
	"labels": ["pc1", "pc2", "pc3", "pc4"],
	"legends": ["OK", "NG"],
	"title": "チャートタイトル"
}
```

##棒グラフ

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/cefe50af-8914-e872-11a0-29044a463793.png)

|必須|名前1|名前2|内容|備考|
|---|---|---|---|---|
|〇|datum||値の配列||
|〇|labels||ラベルの配列| |
||title||タイトル|指定がない場合表示されない|
||range|min|最小値|Y軸の最小値|
|||max|最大値|Y軸の最大値|

```json:例
{
	"datum": [1, 2, 3, 5, 2],
	"labels": ["1月", "2月", "3月", "4月", "5月"],
	"title": "チャートタイトル",
	"range": { "min": 0 }
}
```

#グラフ画像を生成する

widthとheightに画像サイズを指定します。
typeは、先ほどの6種類の名称(doughnut, gauge, line, pie, stackbar, bar)です。
paramsはグラフの種類ごとに指定する値です。
mimetypeは画像のフォーマットを指定します。例えば、”image/png”。

```js:api\controllers\makechart-api\genchart.js
  async makeChart(width, height, type, params, mimetype){
    const chartJSNodeCanvas = new ChartJSNodeCanvas({
      width: width,
      height: height,
      chartCallback: (ChartJS) => {
        ChartJS.defaults.global.defaultFontFamily = FONT_NAME;
        ChartJS.defaults.global.defaultFontColor = FONT_COLOR;
      }
    });
    chartJSNodeCanvas.registerFont(FONT_PATH, { family: FONT_NAME });

    var configuration;
    switch(type){
      case 'doughnut': {
        configuration = make_chart_doughnut(params.value, params.legend, params.title, params.range);
        break;
      }
      case 'gauge': {
        configuration = make_chart_gauge(params.value, params.legend, params.title, params.range);
        break;
      }
      case 'line': {
        configuration = make_chart_line(params.datum, params.labels, params.legends, params.title, params.range);
        break;
      }
      case 'pie': {
        configuration = make_chart_pie(params.datum, params.legends, params.title);
        break;
      }
      case 'stackbar': {
        configuration = make_chart_stackbar(params.datum, params.labels, params.legends, params.title, params.range);
        break;
      }
      case 'bar': {
        configuration = make_chart_bar(params.datum, params.labels, params.title, params.range);
        break;
      }
      default:{
        throw 'unknown type';
      }
    }

    return chartJSNodeCanvas.renderToBuffer(configuration, mimetype);
  }
```

#背景に色を塗ったり、前景全体に文字列を重ね合わせられるようにする

前景全体に文字列を重ね合わせます。

文字列の画像生成には、npmモジュールの「text-to-svg」を使わせていただきました。背景色の生成や画像の合成には、npmモジュールの「sharp」をつかわせていただきました。

```
> npm install text-to-svg
> npm install sharp
```

##文字列画像の生成

chartjs-node-canvasの時と同様に、日本語も表示できるように、日本語フォントを設定します。

```js:api\controllers\makechart-api\genchart.js
const TextToSVG = require("text-to-svg");

const FONT_PATH = '【フォントを配置したフォルダ】' + '/ipaexg.ttf';
const textToSVG = TextToSVG.loadSync(FONT_PATH);
```

あとは、文字列を引数にしてgetSVGを呼び出せばよいのですが、画像サイズいっぱいに表示したいため、フォントサイズを動的に決定するようにしています。

```js:api\controllers\makechart-api\genchart.js
const CAPTION_FONT_SIZE = 72;
const CAPTION_PADDING = 20;

  makeCaption(width, height, caption){
    var fontSize = Math.min(CAPTION_FONT_SIZE, width / caption.length);

    var svgOptions = { x: 0, y: 0, anchor: "left top", attributes: { fill: FONT_COLOR } };
    do {
      svgOptions.fontSize = fontSize;
      var metrics = textToSVG.getMetrics(caption, svgOptions);
      if (metrics.width <= width * (100 - CAPTION_PADDING) / 100 && metrics.height <= height * (100 - CAPTION_PADDING) / 100)
        break;
      fontSize -= 2;
      if( fontSize <= 0 )
        throw 'unknown error';
    } while (true);

    return textToSVG.getSVG(caption, svgOptions);
  }
```

最後に、グラフ画像に対して、背景色と前景文字列をマージします。sharpの機能を活用します。
背景色の画像は、SVGを使って生成しています。
場合によっては、背景色だけつけたい場合、前景文字列だけつけたい場合、いずれも不要な場合があると思いますので、切り替えられるようにしています。

```js:api\controllers\makechart-api\genchart.js
  async generateChart(width, height, type, chart_params, caption, bgcolor){
    var chart_image = await this.makeChart(width, height, type, chart_params, "image/png");
    var caption_image;
    if( caption )
      caption_image = Buffer.from(this.makeCaption(width, height, caption));

    var image_buffer;
    if( bgcolor ){
      var background_svg = `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
								<path d="M0 0 L ${width} 0 L ${width} ${height} L 0 ${height}" style="fill:${bgcolor}; stroke-width:0" />
							</svg>`;
      var comps = [];
      comps.push({ input: chart_image });
      if( caption_image )
        comps.push({ input: caption_image, gravity: "center" });

      image_buffer = await sharp(Buffer.from(background_svg))
        .composite(comps)
        .png()
        .toBuffer();
    }else if( caption_image ){
      image_buffer = await sharp(chart_image)
        .composite([{
          input: caption_image,
          gravity: 'center'
        }])
        .png()
        .toBuffer();
    }else{
      image_buffer = chart_image;
    }

    return image_buffer;
  }
```

#クラスの使い方

結局は、クラス化で、こんな感じで使えるようにしました。

```js:api\controllers\makechart-api\index.js
var image_buffer = await genchart.generateChart(body.width, body.height, body.type, body.chart_params, body.caption, body.bgcolor);
```

#WebAPI使用例

HTTP GETまたはHTTP Post(Json)で、グラフ画像(image/png)が返ってくるWebAPIを作成してみました。

以下の3種類を用意しました。GETの呼び出して呼び出せるようにしています。
Endpoint名：/makechart-inspect

* netdataのグラフ画像
* Ping応答チェッカー
* マシンのメモリ使用状況

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/019744f8-78ef-e09f-defb-18724a3af678.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/5d6a73d4-9287-068e-265b-6381237c4f50.png)
 
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/92b4abc8-7712-102d-7f69-cf3f34d97561.png)

また、POST(Json)呼び出して、データやタイトルやらすべて呼び出し側で指定して画像取得することもできるようにしています。
Endpoint名：/makechart-generate

まとめるとこんな感じです。

```js:api\controllers\makechart-api\index.js
'use strict';

const HELPER_BASE = process.env.HELPER_BASE || '../../helpers/';
const BinResponse = require(HELPER_BASE + 'binresponse');

const { URLSearchParams } = require('url');
const fetch = require('node-fetch');

const genchart = require('./genchart');
const ping = require('ping');
const osu = require('node-os-utils');

const PING_TIMEOUT = 3;

const netdata_base_url = "【netdataのURL】";

exports.handler = async (event, context, callback) => {

	if( event.path == '/makechart-inspect' ){
		console.log(event.queryStringParameters);

		if (event.queryStringParameters.type == 'netdata' ){
			const width = Number(event.queryStringParameters.width);
			const height = Number(event.queryStringParameters.height);
			const chart = event.queryStringParameters.chart || 'system.cpu';

			var qs = {
				chart: chart,
				points: 20,
				format: 'json',
				after: -600,
				group: 'max',
				options: 'jsonwrap'
			};
			var json = await do_get(netdata_base_url + '/api/v1/data', qs);
			console.log(json);

			var labels = [];
			for( var i = 0 ; i < json.points ; i++ ){
//				labels.push(String((qs.after / qs.points) * (json.points - i - 1)) + 's');
				var t = new Date(json.result.data[json.points - i - 1][0] * 1000);
				labels.push(zero2d(t.getHours()) + ':' + zero2d(t.getMinutes()) + ':' + zero2d(t.getSeconds()));
			}
			var datum = [];
			for (var j = 1; j < json.result.labels.length ; j++ ){
				var array = [];
				for (var i = 0; i < json.points; i++) {
					array.push(json.result.data[json.points - i - 1][j]);
				}
				datum.push(array);
			}
			var legends = [];
			for (var i = 1; i < json.result.labels.length ; i++ )
				legends.push(json.result.labels[i]);

			var image_buffer = await genchart.generateChart(width, height, "line", {
				datum: datum,
				labels: labels,
				legends: legends,
				title: 'netdata: ' + chart,
				range: (event.queryStringParameters.max) ? { max: Number(event.queryStringParameters.max) } : undefined
			});

			return new BinResponse("image/png", image_buffer);
		}else
		if( event.queryStringParameters.type == 'ping' ){
			const width = Number(event.queryStringParameters.width);
			const height = Number(event.queryStringParameters.height);
			const hosts = event.queryStringParameters.hosts.split(',');
			const trycount = event.queryStringParameters.trycount ? Number(event.queryStringParameters.trycount) : 3;

			const promises = hosts.map( async item => {
				var result = {
					success: 0,
					error: 0
				};
				for (var i = 0; i < trycount ; i++ ){
					try{
						var res = await ping.promise.probe(item, {
							timeout: PING_TIMEOUT,
						});
						if( res.alive )
							result.success++;
						else
							result.error++;
					}catch(error){
						console.log(error);
						result.error++;
					}
				}
				return result;
			});

			var result = await Promise.all(promises);

			var datum = result.map(item =>{
				return [ item.success, item.error ];
			});

			var image_buffer = await genchart.generateChart(width, height, "stackbar", {
				datum: datum,
				labels: hosts,
				legends: ["OK", "NG"],
				title: 'Pingライフチェック',
			});

			return new BinResponse("image/png", image_buffer);
		}else
		if (event.queryStringParameters.type == 'memory' ){
			const width = Number(event.queryStringParameters.width);
			const height = Number(event.queryStringParameters.height);

			var info = await osu.mem.used();
			var used = info.usedMemMb / info.totalMemMb * 100;

			var image_buffer = await genchart.generateChart(width, height, "gauge", {
				value: used,
				title: '使用メモリ(%)',
				range: { min: 0, max: 100 }
			}, used.toFixed(1) + '%');

			return new BinResponse("image/png", image_buffer);
		}
	} else
	if (event.path == '/makechart-generate') {
		console.log(event.body);
		var body = JSON.parse(event.body);

		var image_buffer = await genchart.generateChart(body.width, body.height, body.type, body.chart_params, body.caption, body.bgcolor);

		return new BinResponse("image/png", image_buffer);
	}
};

function zero2d(val) {
	return ('00' + String(val)).slice(-2);
}

function do_get(url, qs) {
	var params = new URLSearchParams(qs);

	return fetch(url + `?` + params.toString(), {
		method: 'GET',
	})
		.then((response) => {
			if (!response.ok)
				throw 'status is not 200';
			return response.json();
		});
}
```

#終わりに

これで、GET呼び出しで、グラフ画像が取得できるようになったので、LCD付のESP32で、稼働監視を可視化できそうです。

以上