Chart.js をサーバーサイドで使う方法

[Chart.js](https://www.chartjs.org/) は、JavaScriptで利用できるメジャーなチャート(グラフ)描画ライブラリーの1つですが、HTML DOM、より正確に言えば [HTML5 Canvas API](https://developer.mozilla.org/ja/docs/Web/HTML/Canvas) に依存しているため、Node.jsの環境では動作できません。

しかし、Canvas以外への依存は殆ど無いので、Canvas APIさえ用意できれば、サーバーサイド・レンダリング (SSR) が可能となります。

今回は私自作のCanvas API互換ライブラリー [red-agate-svg-canvas](https://www.npmjs.com/package/red-agate-svg-canvas) を使用して、Node.js上でSVGにレンダリングしたいと思います。

今回の全ソースは [こちら](https://github.com/shellyln/chart.js-node-ssr-example) にあります。

## 追記 (2018/9/18)
今回説明する以外に[chartjs-node](https://www.npmjs.com/package/chartjs-node)等のパッケージを用いてもサーバーサイドでChart.jsを使うことができます。
chartjs-nodeのメリットは、jpegやpngといったラスタイメージ、svg、さらにはpdf出力にも対応しているところですが、それらを実現するために[cairo](https://www.cairographics.org/)というネイティブのライブラリーに依存しているため、セットアップは少し[面倒](https://github.com/Automattic/node-canvas#installation)となっています。

# 解説

```ts
import { SvgCanvas,
         Rect2D,
         SvgCanvas2DGradient } from 'red-agate-svg-canvas/modules';
import * as ChartJs            from 'chart.js';

// グローバルスコープを取得します。
// もし、Node.jsで動作してるならば、 "g" は "global" オブジェクトになります。
// ブラウザー上ならば、 "g" は "window" オブジェクトになります。
const g = Function('return this')();

// グラフの描画オプションです。
// https://www.chartjs.org/docs/latest/getting-started/usage.html
const opts: any = { /* 中略 */ };


function main() {
    // SvgCanvas は "CanvasRenderingContext2D" と互換のインターフェイスを持っています。
    const ctx = new SvgCanvas();

    // しかしながら、 SvgCanvas には canvas プロパティーがありません。
    // canvas プロパティーには本来、このコンテキストの親となる HTMLCanvasElement が設定されています。
    // Chart.js では、以下のプロパティーのみ存在すれば問題ありません。
    (ctx as any).canvas = {
        width: 800,
        height: 400,
        style: {
            width: '800px',
            height: '400px',
        },
    };

    // SvgCanvas の実装は、ホスト環境のフォント(グリフ)情報にアクセスしません。
    // 文字列のピクセルでの幅を求められるようにするために、フォントの縦横比を手動で設定します。
    ctx.fontHeightRatio = 2;

    // Chart.js には "HTMLCanvasElement" と互換のインターフェイスを持つオブジェクトを渡す必要があります。
    // そのオブジェクトは、"getContext()" メソッドのみを持っていれば問題ありません。
    // "getContext()" は "CanvasRenderingContext2D" と互換のインターフェイスを持つオブジェクトを返さなければなりません。
    const el = { getContext: () => ctx };

    // もし、描画オプションに "devicePixelRatio" を設定しないと、
    // Chart.js は devicePixelRatio を "window" オブジェクトから取得しようとします。
    // その場合、 node.js では window オブジェクトが存在しないため、エラーとなってしまいます。
    opts.options.devicePixelRatio = 1;

    // アニメーションを無効にします。
    opts.options.animation = false;
    opts.options.events = [];
    opts.options.responsive = false;

    // Chart.js は描画設定によっては、グローバルスコープの "CanvasGradient" を参照しようとします。
    // エラーを防ぐために、 SvgCanvas のグラデーションオブジェクトをグローバルスコープに設定します。
    const savedGradient = g.CanvasGradient;
    g.CanvasGradient = SvgCanvas2DGradient;
    try {
        const chart = new ChartJs.Chart(el as any, opts);
    } finally {
        // グローバルスコープを元に戻します。
        if (savedGradient) {
            g.CanvasGradient = savedGradient;
        }
    }

    // SVGとして描画します。
    const svgString = ctx.render(new Rect2D(0, 0 , 800, 400), 'px');
    console.log(svgString);
}
```

# レンダリング結果 (Chromeでの表示結果)
* SVGイメージはQiitaのエディターでアップロードされないため、Chromeで表示してキャプチャーしました。
    * 実際のSVGは[こちら](https://github.com/shellyln/chart.js-node-ssr-example#rendering-results)です。

## [バー](https://www.chartjs.org/docs/latest/getting-started/usage.html)
![1.png](https://qiita-image-store.s3.amazonaws.com/0/185253/dc05fbfc-cdd0-4db2-514e-73e2dd17d3af.png)

## [ドーナッツ](http://www.chartjs.org/samples/latest/charts/pie.html)
![2.png](https://qiita-image-store.s3.amazonaws.com/0/185253/5d8cd217-0085-f9e4-fc31-b5da57349733.png)

## [ライン](http://www.chartjs.org/samples/latest/charts/line/point-sizes.html)
![3.png](https://qiita-image-store.s3.amazonaws.com/0/185253/60731a7e-8e66-1603-4fe5-668b295a89e0.png)

## [エリア](http://www.chartjs.org/samples/latest/charts/area/line-stacked.html)
![4.png](https://qiita-image-store.s3.amazonaws.com/0/185253/d0abc714-a972-7c79-8af5-7fcd09cfa155.png)

# 備考

* 現状、ビルドには Webpack と babel が必要です。

----

# さいごに
上記のチャートやUML、バーコード・2次元コードをドキュメントに組み込んでHTMLやPDFにレンダリングする
コンソールアプリ [Ménneu](https://www.npmjs.com/package/menneu) を公開しました。
後発のアプリですが、併せてご覧いただければと思います。