LoginSignup
7
7

More than 1 year has passed since last update.

node.jsでchart.jsを使う

Last updated at Posted at 2021-10-09

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

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

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

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

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

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

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

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

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

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

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

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

必須 名前1 名前2 内容 備考
value
legend 凡例 指定がない場合表示されない
title タイトル 指定がない場合表示されない
range max 最大値 100%となる値
{
    "value": 10,
    "legend": "凡例1",
    "title": "チャートタイトル",            
    "range": { "max": 50 }
}

横棒ゲージのようなもの

image.png

必須 名前1 名前2 内容 備考
value
legend 凡例 指定がない場合表示されない
title タイトル 指定がない場合表示されない
range min 最小値 0%となる値
max 最大値 100%となる値
{
    "value": 10,
    "legend": "凡例1",
    "title": "チャートタイトル",            
    "range": { “min”: 0, "max": 50 }
}

折れ線グラフ

image.png

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

必須 名前1 名前2 内容 備考
datum 値の配列
legends 凡例の配列 指定がない場合表示されない
title タイトル 指定がない場合表示されない
{
    "datum": [880, 740, 100],
    "legends": ["OK", "NG", "UNKNOWN"],
    "title": "チャートタイトル"
}

積み上げ棒グラフ

image.png

必須 名前1 名前2 内容 備考
datum 値の2次元配列
labels ラベルの配列
legends 凡例の配列
title タイトル 指定がない場合表示されない
{
    "datum": [[1, 4], [5, 0], [3, 2], [4, 1]],
    "labels": ["pc1", "pc2", "pc3", "pc4"],
    "legends": ["OK", "NG"],
    "title": "チャートタイトル"
}

棒グラフ

image.png

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

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の時と同様に、日本語も表示できるように、日本語フォントを設定します。

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

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

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

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を使って生成しています。
場合によっては、背景色だけつけたい場合、前景文字列だけつけたい場合、いずれも不要な場合があると思いますので、切り替えられるようにしています。

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;
  }

クラスの使い方

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

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

image.png

image.png

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

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

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で、稼働監視を可視化できそうです。

以上

7
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
7