Billboard.jsとjsdomでSVGを生成する（ちょっと失敗編）

たまには、あまりうまくいかなかった事でも書き残しておくかと。

## やりたかった事

日々、考えた事等の記録を 個人的なBlog(公開はしていません)に Markdownで 書き残しています。そのときに、ちょっとしたダイアグラムを残したいときは、Markdownの Rendererに PlantUMLを組み込んで使っていました。

先日、ちょっとグラフ（Chart）を残したいなぁと思って、[Billboard.js] を Markdown に組み込む事を試してみたので、その記録です。

[Billboard.js]: https://naver.github.io/billboard.js/
[PlantUML]: https://plantuml.com/ja/

## 正攻法

これを実際にやるなら [Puppeteer] と [Billboard.js] を組み合わせるのが、一番楽な道かと思います。

実際 [Billbaord.js]の公式にある、[How to generate chart image in Node.js environment?](https://github.com/naver/billboard.js/wiki/How-to-generate-chart-image-in-Node.js-environment%3F)に、Screenshotを使った .png生成のやり方が書かれています。

これをちょっと変更して、DOMから SVGタグを抜き出せばよいのではないかと。

[Puppeteer]: https://pptr.dev/

## 裏道

正攻法はありますが、[Puppeteer] は Headlessで動かしても、起動に時間かかったりする等あって軽量に動かせる感じがしません（個人の印象です）。

なら、[jsdom] 使ってみたらどうかな？と思ったので、これに挑戦してみます。

[jsdom]: https://github.com/jsdom/jsdom

## やり方

MarkdownのRendererへの組み込みは簡単なので、とりあえず、JSON渡したら SVGを吐く事を試してみます。

### 0. 環境等

node.js/npm でやります。

```bash
npm --version
```

私の環境では `v20.11.1` を使います。

### 1. プロジェクトを作る

```bash
{
    mkdir json2svg
    cd json2svg
    npm init -y
}
```

### 2. 必要ライブラリのインストール

```bash
{
    npm install billboard.js jsdom
}
```

### 3. 書いてみる

#### 3.1 初期コード

最初に書いたのはこのコード

```javascript:index.js
const fs = require("fs");
const { JSDOM } = require("jsdom");

const dom = new JSDOM('<!DOCTYPE html><html><head></head><body><div id="Chart"></div></body></html>');

const { bb } = require("billboard.js");

const json = JSON.parse(fs.readFileSync("chart.json", "utf-8"));

// 描画エリアの指定
json.bindto = "#Chart";
json.size = { width: 640, height: 480 };
// アニメーションの停止
json.duration = 0;

bb.generate(json);
console.log(document.getElementsByTagName("svg")[0].outerHTML);
```

jsonは [Billboard.js - Example](https://naver.github.io/billboard.js/demo/)から適当にもってきました。

```json:chart.json
{
    "data": {
        "columns": [
            [ "data1", 30, 200, 100, 400, 150, 250 ],
            [ "data2", 50, 20, 10, 40, 15, 25 ]
        ],
        "type": "line"
    }
}
```

Browser環境との違いとして、Globalの名前空間に違いがあるので多少それを埋めてやる必要があります。

#### 3.2 Browser環境との違いを埋める

[Billboard.js]を読みだした時点で、この差がうまっていないといけないので
`require('billboard.js')` する前に、以下をはさみます。

```javascript:index.js
document = dom.window.document;
navigator = dom.window.navigator;
navigator.userAgent = "Mozilla/5.0";
global.addEventListener = function(event_type, listener, options) {}
```

これで　`node index.js` 等として動かしてみると、`bb.generate()` 時にエラーが出ます。

```console
/.../json2svg/node_modules/billboard.js/dist/billboard.pkgd.js:41956
          eventReceiver.rect = rectElement.node().getBoundingClientRect().toJSON();
```

#### 3.3 JSDOMの動きを修正する

`Node.getBoundingClientRect()` は 変更不可のDOMRectとして戻ってくるので、それを変更できるように`toJSON()`を呼び出しています。

[JSDOM] に手を入れずに解決するため、DOMRectの代替クラスを作って、toJSONを実装します。

```javascript
// toJSONエラー対策済み DOMRect
class _DOMRect {
    constructor(x, y, width, height) {
        this.x = x;
        this.y = y;
        this.width = width;
        this.height = height;
    }
    toJSON() {
        return this;
    }
}
```

更に Nodeクラスの プロトタイプにある`getBoudingClientRect()`関数を置き換えます。

```javascript
let NodePrototype = dom.window.document.createElement('p').__proto__.__proto__.__proto__;
NodePrototype.getBoundingClientRect = function() {
    return new _DOMRect(0, 0, 0, 0);
};
```

丁寧にやるなら、originalの関数を呼び出し、その内容にあわせて `new _DOMRect`しても良いかと思います。

`bb.generate()`を呼び出す前に、これらの変更をいれておきます。

#### 4. 実行してみる

最終的なコードは以下のようになります。

```javascript
const fs = require("fs");
const { JSDOM } = require("jsdom");

const dom = new JSDOM('<!DOCTYPE html><html><head></head><body><div id="Chart"></div></body></html>');

// global 関係のfix
document = dom.window.document;
navigator = dom.window.navigator;
navigator.userAgent = "Mozilla/5.0";
global.addEventListener = function(event_type, listener, options) {}

const { bb } = require("billboard.js");

// toJSONエラー対策済み DOMRect
class _DOMRect {
    constructor(x, y, width, height) {
        this.x = x;
        this.y = y;
        this.width = width;
        this.height = height;
    }
    toJSON() {
        return this;
    }
}

let NodePrototype = dom.window.document.createElement('p').__proto__.__proto__.__proto__;
NodePrototype.getBoundingClientRect = function() {
    return new _DOMRect(0, 0, 0, 0);
};

const json = JSON.parse(fs.readFileSync("chart.json", "utf-8"));

// 描画エリアの指定
json.bindto = "#Chart";
json.size = { width: 640, height: 480 };
// アニメーションの停止
json.duration = 0;

bb.generate(json);
console.log(document.getElementsByTagName("svg")[0].outerHTML);
```

表示されたSVGにCSSを適用して確認してみると以下のような感じになりました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/128512/492df993-fd7e-b68b-00a3-aae5de41aed5.png)

これで 概ね成功なのですが、用例のテキストが重なってしまっています。
また、左端の数値も はみ出した感じでレイアウトされてしまっています。

:::note info
[Billboard.js]の cssをそのまま適用する場合、`<div class="bb"></div>` 等のコンテナに入れてやる必要があります。
:::

## 失敗した点

[Billboard.js]の中で `getBoundingClientRect` の呼出しは テキストのレイアウトのためにも呼ばれています。

このため、SVGの中の Textエレメントに対して呼ばれたときは、適切な幅、高さを返してやる必要があります。

[jsdom] でレイアウト系はスコープ外と仕様上決められています。もともと、`getBoundingClientRect`は0,0,0,0 以外の情報を戻しません。

このサイズを正確に計算をするためには フォントをレンダリングしたときのピクセル数を調べる必要があります。この情報は残念ながら CSSおよび設定されたフォントの中にあるため、簡単には手が出ません。

また、SVGでは transform属性で アフィン変換が行えますので、それらを反映した BoundingClientRectを返すのは難しい様に思いましたので、私は諦めてしまいました。

ただ、回転をさせている例はグラフの軸の説明等で90度単位の回転くらいな気がしています。簡単な対応として、textエレメントに含まれる文字数から、フォントサイズは固定で計算して返してやるだけでもとりあえずは使えそうですので、そのうち時間があればやってみます。