Chart.js でY軸を表示しながらグラフを横スクロールする【デモページあり】

下記画像を見て「ぴん」とくる方むけの記事です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/121763/8846562b-1239-59bc-f103-417d55702016.png)

**2020/11/3 追記**
`<app>` タグで囲まれたときの問題について追記しました。

# デモページ
下記 JSFiddle ページにデモがあります。
https://jsfiddle.net/oktopus1959/w4gan9tk/23/

適当にソースを修正して「Run」を実行すると、その修正を反映した表示がされます。いろんな値をいじって、表示がどのように変化するかを見るのも面白いですよ。

また、左右のY軸を固定表示したグラフを使用したサイトも作成しました[^sample-site]。
https://ncov.oktopus59.net/

[^sample-site]: 当該サイトのソースコードを https://github.com/oktopus1959/NcovChartBlazorApp に公開してあります。Chart.js を使っている部分は DrawChart.js にまとめてあります。

# 基本アイデア
「[参考](#参考)」にも書きましたが、

- Y軸のイメージを別の canvas にコピーして元のイメージと重なるように配置する

です。

# ソースコード
Chart.js, JavaScript, CSS にある程度慣れている方なら、コメントを読めばやっていることが理解できるかと思います[^todo]。

[^todo]: コピーすべきY軸イメージの幅や高さの計算など、流用コードの一部で理解できていない部分があるので、どなたかお分かりになる方がいればコメントいただければ幸いです。（「TODO」を付けているところ）

<details><summary>**ソースコード**（クリックして開いてください）</summary><div>

```html
<head>
<style>
/* 配置の基準となるラッパーブロック; とりあえず背景色もここで設定している */
.scrollableChartWrapper {
  position: relative;
  background-color: ghostwhite;
}

/* スクロール可能グラフを囲む div */
.scrollableChartWrapper > div {
  position: relative;
  overflow-x: scroll;
}

/* Y軸イメージコピー用 canvas */
.scrollableChartWrapper > canvas {
  position: absolute;            /* これにより、上の div と重なる位置に canvas が配置される */
  left: 0;
  top: 0;
  background-color: ghostwhite;  /* ここをコメントアウトすると、背景色が透明になるので、Y軸を透かしてグラフ部分が見えるようになる */
}
</style>
</head>

<body>

<!-- 以下 HTML 部分 -->
<!-- スクロール可能グラフ領域のラッパーブロック -->
<div class="scrollableChartWrapper">
  <!-- スクロールされる canvas を持つ div:
       - style.width を省略すると div の幅がページ幅に合わせられる;
       - style.width を指定すると div の幅（≒スクロールバーの長さ）が固定される -->
  <div>
    <!-- グラフ描画用 canvas:
         - style.height は必須;
         - style.width は全データを表示するのに必要なグラフ幅であり、JS によって設定する;
         - width,height は Chart により設定される -->
    <canvas id="chart" style="height: 250px"></canvas>
  </div>
  <!-- Y軸イメージコピー用 canvas: {style.,}{height,width} は JS によって設定する -->
  <canvas id="yAxis" width="0"></canvas>
</div>

<!-- 以下 JavaScript 部分 -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.9.3/Chart.js"></script>

<script>

// Y軸コピー用 canvas
var cvsYAxis = document.getElementById('yAxis');
var ctxYAxis = cvsYAxis.getContext('2d');

// テスト用データの用意
var data = [];
var labels = [];
var colors = [];

for (i = 0; i < 15; i += 1) {
  [12, 19, 3, 5, 2, 3].forEach(x => { data.push(x); });
  ['Red', 'Blue', 'Yellow', 'Green', 'Purple', 'Orange'].forEach(x => { labels.push(x); });
  [ 'rgb(255, 99, 132)',
    'rgb(54, 162, 235)',
    'rgb(255, 206, 86)',
    'rgb(75, 192, 192)',
    'rgb(153, 102, 255)',
    'rgb(255, 159, 64)'].forEach(x => { colors.push(x); });
}

// X軸の1データ当たりの幅
var xAxisStepSize= 30;
// グラフ全体の幅を計算
var chartWidth = data.length * xAxisStepSize;

// グラフ描画用 canvas
var cvsChart = document.getElementById('chart');
var ctxChart = cvsChart.getContext('2d');

// グラフ描画用canvasのstyle.width(すなわち全データを描画するのに必要なサイズ)に上記の幅を設定
cvsChart.style.width = chartWidth + "px";

// canvas.width(height)はグラフ描画時に Chart により変更される(らしい)ので、下記は不要
//cvsChart.width = chartWidth;
//cvsChart.height = chartHeight;

console.log("Before chart canvas width=" + cvsChart.style.width);
console.log("Before chart canvas height=" + cvsChart.style.height);

// 二重実行防止用フラグ
var copyYAxisCalled = false;

// Y軸イメージのコピー関数
function copyYAxisImage(chart) {
  //console.log("copyYAxisCalled="+copyYAxisCalled);

  if (copyYAxisCalled) return;

  copyYAxisCalled = true;

  // グラフ描画後は、canvas.width(height):canvas.style.width(height) 比は、下記 scale の値になっている
  var scale = window.devicePixelRatio;

  // Y軸のスケール情報
  var yAxScale = chart.scales['y-axis-0'];

  // Y軸部分としてグラフからコピーすべき幅 (TODO: 良く分かっていない)
  var yAxisStyleWidth0 = yAxScale.width - 10;

  // canvas におけるコピー幅(yAxisStyleWidth0を直接使うと微妙にずれるので、整数値に切り上げる)
  var copyWidth = Math.ceil(yAxisStyleWidth0 * scale);
  // Y軸canvas の幅(右側に少し空白部を残す)
  var yAxisCvsWidth = copyWidth + 4;
  // 実際の描画幅(styleに設定する)
  var yAxisStyleWidth = yAxisCvsWidth / scale;

  // Y軸部分としてグラフからコピーすべき高さ (TODO: 良く分かっていない) ⇒これを実際の描画高とする(styleに設定)
  var yAxisStyleHeight = yAxScale.height + yAxScale.top + 10;
  // canvas におけるコピー高
  var copyHeight = yAxisStyleHeight * scale;
  // Y軸canvas の高さ
  var yAxisCvsHeight = copyHeight;

  console.log("After chart canvas width=" + cvsChart.width);
  console.log("After chart canvas height=" + cvsChart.height);
  console.log("scale="+scale);
  console.log("copyWidth="+copyWidth);
  console.log("copyHeight="+copyHeight);
  console.log("yAxisCvsWidth="+yAxisCvsWidth);
  console.log("yAxisCvsHeight="+yAxisCvsHeight);
  console.log("yAxisStyleWidth0="+yAxisStyleWidth0);
  console.log("yAxisStyleWidth="+yAxisStyleWidth);
  console.log("yAxisStyleHeight="+yAxisStyleHeight);

  // 下記はやってもやらなくても結果が変わらないっぽい
  //ctxYAxis.scale(scale, scale);

  // Y軸canvas の幅と高さを設定
  cvsYAxis.width = yAxisCvsWidth;
  cvsYAxis.height = yAxisCvsHeight;

  // Y軸canvas.style(実際に描画される大きさ)の幅と高さを設定
  cvsYAxis.style.width = yAxisStyleWidth + "px";
  cvsYAxis.style.height = yAxisStyleHeight + "px";

  // グラフcanvasからY軸部分のイメージをコピーする
  ctxYAxis.drawImage(cvsChart, 0, 0, copyWidth, copyHeight, 0, 0, copyWidth, copyHeight);

  // 軸ラベルのフォント色を透明に変更して、以降、再表示されても見えないようにする
  chart.options.scales.yAxes[0].ticks.fontColor = 'rgba(0,0,0,0)';
  chart.update();
  // 最初に描画されたグラフのY軸ラベル部分をクリアする
  ctxChart.clearRect(0, 0, yAxisStyleWidth, yAxisStyleHeight);
}

// グラフ描画
var myChart = new Chart(ctxChart, {
    type: 'bar',
    data: {
      labels: labels,
        datasets: [{
            label: '# of Votes',
            data: data,
            backgroundColor: colors,
            borderColor: colors,
        }]
    },
    options: {
      responsive: false,  // true（デフォルト）にすると画面の幅に合わせてしまう
      scales: {
        yAxes: [{
          ticks: {
            beginAtZero: true
          }
        }]
      }
    },
    plugins: [{
      // 描画完了後に copyYAxisImage() を呼び出す
      // see https://www.chartjs.org/docs/latest/developers/plugins.html
      //     https://stackoverflow.com/questions/55416218/what-is-the-order-in-which-the-hooks-plugins-of-chart-js-are-executed
      afterRender: copyYAxisImage
    }]
});
</script>

</body>
```
</div></datails>

# 読解のためのヒント
[^2]: 未来の自分に向けたものでもあります。

- グラフ描画用 canvas と、そのY軸イメージをコピーして表示する canvas を用意する
- 2つの canvas の配置を決定できるように、外側に位置の基準となる div を配置 (これが `.scrollableChartWrapper`)<p/>
- 水平スクロールバーを表示するには、グラフ用 canvas を div で囲んで、その div に `style="overflow-x: scroll"` を付加する （CSS の `.scrollableChartWrapper > div` で設定してあるので、 HTML上では何も書く必要なし）
- 上記 div と同じレベルで Y軸イメージコピー用 canvas を配置し、`position: absolute` を指定して、グラフ用 canvas に重ねて表示する（CSS の `.scrollableChartWrapper > canvas` で設定してあるので、 HTML上では何も書く必要なし）<p/>
- scrollable div の幅（≒スクロールバーの長さ）をページ幅に合わせる場合は、各 `<div>` タグに付加している `position: relative` が必須（div で style.width を設定する場合は absolute でもOK）
- グラフ用 canvas の `style.width` はJSで計算して設定している（が、HTML部分で設定してもよい；この幅が 外側の scrollable div の幅を超えるとスクロールされるようになる）
- Chart の `responsive` 属性は _false_ にしておく（_true_ (default) の場合、ページ幅に応じてグラフ用 canvas の style.width が調節されてしまう）<p/>
- グラフの描画完了のタイミングで、Y軸イメージをコピーする plugin 関数 `copyYAxisImage()` を呼び出す
- 軸ラベルのフォントを透明色に変更して、以後の再描画によって軸ラベルが再表示されても目には見えないようにする
- `copyYAxisImage()` の末尾でコピー元のY軸イメージをクリアして、最初に表示されていた軸ラベルを消去する
- デバイスやブラウザ、ズーム率によって `window.devicePixelRatio` の値が異なり、これがグラフ canvas の width と height に影響するので、Y軸コピー用 canvas を用意する際は、それを考慮する必要がある<p/>
- とにかく canvas と Chart と responsive が絡むと本当に混乱する

# `<app>` タグで囲まれたときの問題点
ASP.NET Core 3.1 の Blazor Server プロジェクトのサンプルコードは、ページ部分が `<app>` タグで囲まれています（[参考記事](https://qiita.com/okatako/items/c0208510961982df4bdc#apprazor-mainlayoutrazor-navmenurazor-and-_hostcshtml)）。

同じように上記ソースの HTML部分を `<app>` タグで囲んでみてください。（下図参照）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/121763/35c40f9a-a4c5-482d-739a-bae8f4dbd724.png)
こうすると、ページ幅が広いときは、なぜか外側の `<div>` の幅がグラフ canvas の幅に合わせられてしまいます。ページ幅を狭くしていくと、突然 `<div>` の幅がページ幅に合って、スクロールバーが表示されるようになります。

参考記事の[脚注](https://qiita.com/okatako/items/c0208510961982df4bdc#fn13)にも書きましたが、`<app>`タグはすでに deprecated になっているので、ここはたんに `<div>` に置き換えてしまってもよいかと思います。


# 参考
下記を参考にさせていただきました。

- [Chart.jsで横スクロール可能なグラフを作る](https://qiita.com/yutake27/items/fa7b1f6b3c7c65e9d69b)
- https://jsfiddle.net/EmmaLouise/eb1aqpx8/3/

Y軸のイメージをコピーする、というアイデアは後者のページに負っています（というか、かなりソースを流用させてもらってます）。

ただ、後者の実装では `<div>` が三重になっているのですが、その理由がよく分からず[^triple-div]。筆者の実装では `<div>` を二重で済ませてあります。その他、 plugin の呼び出しも初めの1回だけにするなど、いくつかの改良も施してあります。

[^triple-div]: いったんグラフを構築してから、追加でデータを push して、その増えた分だけ div の幅を広げるような操作をしているので、そのために必要なのかもしれないのですが、よく理解できていません。

# 余談
ここまで書いておいて今さらなんですが、このようにY軸を固定したりするなら、SVG系のチャートライブラリを使うほうがよさそうです。ちなみにNHKの[新型コロナ特設サイト](https://www3.nhk.or.jp/news/special/coronavirus/data-all/)では [Highcharts](https://www.highcharts.com/blog/products/highcharts/)[^highcharts]を使っているようです。このサイトをブラウザの開発者画面で見ると、Y軸ラベル、Y軸、グラフエリアに要素が分かれています。

SVG系チャートライブラリについては、「[Chart ツールは Canvas より SVG のほうがよさそう](http://var.blog.jp/archives/79162948.html)」というページがまとまっているかと思います。

[^nhk]: ページを拡大・縮小させると分かるんですが、Y軸エリアが微妙にスクロールバーにかかっていたりするのはご愛嬌。

[^highcharts]: 商用で使うには有料版を購入する必要があるっぽいですね。