📊

最大寸法に制限されずにスクロール可能なcanvasを描画する方法

2024/09/21に公開

背景

canvasには描画領域の最大寸法が存在する。
この寸法を超えた場合、canvasが利用できなくなる。
以下はMDNのこちらのページから引用。

この表を見ると、x軸方向とy軸方向ともに最大30000px程度しか利用できない。
※IEはサポート終了のため。
最大面積も設定されているため、正方形に近い形で利用するならx軸方向とy軸方向ともに最大16000px程度となる。

ブラウザー 最大高 最大幅 最大面積
Chrome 32,767 pixels 32,767 pixels 268,435,456 pixels (つまり 16,384 x 16,384)
Firefox 32,767 pixels 32,767 pixels 472,907,776 pixels (つまり 22,528 x 20,992)
Safari 32,767 pixels 32,767 pixels 268,435,456 pixels (つまり 16,384 x 16,384)
IE 8,192 pixels 8,192 pixels ?

canvasに描画した内容をスクロール可能にする場合、一般的には全データをcanvasに描画する。
そして、表示領域からはみ出した場合はスクロールすることで表示させることが多い。

データ量が少ない場合はこれで問題ない。
しかし、データ量が多い場合は全データを描画した際に最大寸法を超える可能性がある。
この場合、canvasが動作しなくなる。

この問題を解決する方法を考えた。

完成

完成形だけ見たい方は以下のURLからどうぞ。
https://yaona807.github.io/canvas-scroll/

コードはこちら。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Canvas Scroll</title>
    <style>
        body {
            margin: 0;
            display: flex;
            justify-content: center;
            align-items: center;
            height: 100vh;
            background-color: #f0f0f0;
        }

        .main {
            width: 800px;
            overflow: hidden;
        }

        canvas {
            border: 1px solid black;
        }

        .scroll-wrapper {
            overflow-y: hidden;
        }

        .scroll-core {
            height: 1px;
        }
    </style>
    <!-- Chart.js CDN -->
    <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
</head>
<body>

    <div class="main">
        <canvas id="myChart" width="800" height="400"></canvas>
        <div class="scroll-wrapper">
            <div class="scroll-core"></div>
        </div>
    </div>

<script>
    const totalDataPoints = 100000;  // データポイントの総数
    const maxDisplayDataLimit = 10;  // 表示するデータポイント数の上限
    let currentIndex = 0;  // 現在の表示位置

    // ランダムデータ生成関数
    function generateRandomData(count, min, max) {
        return Array.from({length: count}, () => Math.floor(Math.random() * (max - min + 1)) + min);
    }

    // グラフの表示範囲を更新する関数
    function updateChartRange(startIndex) {
        const endIndex = startIndex + maxDisplayDataLimit;
        myChart.data.labels = labels.slice(startIndex, endIndex);
        myChart.data.datasets[0].data = randomData.slice(startIndex, endIndex);
        myChart.update();
    }

    // ホイールイベントハンドラー
    function onWheel(evt) {
        if (evt.shiftKey) {
            scrollWrapper.scrollLeft += evt.deltaY;
        } else if (evt.deltaX) {
            scrollWrapper.scrollLeft += evt.deltaX;
        }
    }

    // スクロールイベントハンドラー
    function onScroll(evt) {
        const scrollLeft = evt.target.scrollLeft;
        // 現在のスクロール位置 / 1目盛りの幅 = 現在のインデックス
        const newStartIndex = Math.round(scrollLeft / (800 / (maxDisplayDataLimit - 1)));
        if (newStartIndex !== currentIndex) {
            currentIndex = newStartIndex;
            updateChartRange(currentIndex);
        }
    }

    // 初期化用のデータ
    const randomData = generateRandomData(totalDataPoints, 0, 100);
    const labels = Array.from({length: totalDataPoints}, (_, i) => `Label ${i + 1}`);

    const canvas = document.getElementById('myChart');
    const scrollWrapper = document.querySelector('.scroll-wrapper');
    const scrollCore = document.querySelector('.scroll-core');

    // 1目盛りの幅 * データの総数 = 最大スクロール量
    scrollCore.style.width = `${(800 / (maxDisplayDataLimit - 1) * (totalDataPoints - 1))}px`;

    scrollWrapper.addEventListener('scroll', onScroll);
    canvas.addEventListener('wheel', onWheel);

    // グラフの初期設定
    const data = {
        labels: labels.slice(0, maxDisplayDataLimit),
        datasets: [{
            label: 'Random Data',
            backgroundColor: 'rgba(75, 192, 192, 0.2)',
            borderColor: 'rgba(75, 192, 192, 1)',
            borderWidth: 1,
            data: randomData.slice(0, maxDisplayDataLimit),
        }]
    };

    const config = {
        type: 'line',
        data: data,
        options: {
            responsive: true,
            scales: {
                x: {
                    ticks: {
                        maxTicksLimit: maxDisplayDataLimit,
                    }
                },
                y: {
                    max: 100,
                    min: 0,
                    beginAtZero: true
                }
            }
        }
    };

    // グラフを描画
    const myChart = new Chart(
        canvas,
        config
    );
</script>

</body>
</html>

ロジック

最大寸法を超える原因はcanvasに全データを描画する、つまり表示領域に表示されないところまで描画しているためである。
イメージとしては以下となる。
以下のように、表示領域外にもcanvasが描画されているため、最大寸法を超えてしまう。

そのため、解決策はシンプルで、「表示領域内だけに描画する」だけである。
もちろん、上記だけだとスクロールに対応できないため、それも考慮する必要がある。
そこで考えた方法が、canvasとスクロールバーを分離させる方法である。
イメージとしては以下となる。

ロジックとしては単純で、

  1. 全データをcanvasに描画した場合のスクロール長を計算する
  2. canvasとは切り離したスクロールバーを設置し、先ほど計算したスクロール長を設定する
  3. scrollイベントが発火時、現在のスクロール位置から表示すべきデータを導出する
  4. 導出したデータをcanvasに描画する

となる。

コード解説

グラフの描画にはChart.jsを利用。

HTML

canvasとスクロールバーを分離させるため、canvasタグの下にスクロールバー用の要素を追加する。
scroll-coreはスクロール長を設定する要素、scroll-wrapperはスクロールバーを表示するための要素となる。
scroll-coreに設定された幅がscroll-wrapperを超えるとスクロールバーが表示される。

    <div class="main">
        <canvas id="myChart" width="800" height="400"></canvas>
        <div class="scroll-wrapper">
            <div class="scroll-core"></div>
        </div>
    </div>

JavaScript

本実装で表示するデータの総数は100000
表示領域に表示するデータ数は10
初期表示の際に先頭となるindexは0
とする。

    const totalDataPoints = 100000;  // データポイントの総数
    const maxDisplayDataLimit = 10;  // 表示するデータポイント数の上限
    let currentIndex = 0;  // 現在の表示位置

0から100の値をデータポイントの総数分だけ生成して、randomDataに格納する。
labelsにはLabels{ (index + 1) }となるようなラベル名を生成して格納する。

    // ランダムデータ生成関数
    function generateRandomData(count, min, max) {
        return Array.from({length: count}, () => Math.floor(Math.random() * (max - min + 1)) + min);
    }

    // 初期化用のデータ
    const randomData = generateRandomData(totalDataPoints, 0, 100);
    const labels = Array.from({length: totalDataPoints}, (_, i) => `Label ${i + 1}`);

1目盛りの幅を算出し、データポイントの総数と掛け合わせることで、全データが描画された際の実際のスクロール長を導出する。
※800はcanvasの幅である。

    const scrollCore = document.querySelector('.scroll-core');

    // 1目盛りの幅 * データの総数 = 最大スクロール量
    scrollCore.style.width = `${(800 / (maxDisplayDataLimit - 1) * (totalDataPoints - 1))}px`;

スクロールバーの移動を検知するために、scrollWrapperscrollイベントを監視する。
scrollイベントが発火された場合はscrollLeftから現在のスクロール位置を取得し、描画範囲を算出する。
算出した範囲が現在と異なる場合はcanvasを更新する。

上記だけでは、canvas内でのスクロール動作を検知できない。
そのため、canvasのwheelイベントを監視し、wheelイベントが発火した場合はscrollWrapperscrollLeftをホイール量だけ手動で変更させる。
scrollLeftが更新された場合はscrollイベントが発火されるため、canvasの描画が自動で更新される。

    // グラフの表示範囲を更新する関数
    function updateChartRange(startIndex) {
        const endIndex = startIndex + maxDisplayDataLimit;
        myChart.data.labels = labels.slice(startIndex, endIndex);
        myChart.data.datasets[0].data = randomData.slice(startIndex, endIndex);
        myChart.update();
    }

    // ホイールイベントハンドラー
    function onWheel(evt) {
        if (evt.shiftKey) {
            scrollWrapper.scrollLeft += evt.deltaY;
        } else if (evt.deltaX) {
            scrollWrapper.scrollLeft += evt.deltaX;
        }
    }

    // スクロールイベントハンドラー
    function onScroll(evt) {
        const scrollLeft = evt.target.scrollLeft;
        // 現在のスクロール位置 / 1目盛りの幅 = 現在のインデックス
        const newStartIndex = Math.round(scrollLeft / (800 / (maxDisplayDataLimit - 1)));
        if (newStartIndex !== currentIndex) {
            currentIndex = newStartIndex;
            updateChartRange(currentIndex);
        }
    }

    const canvas = document.getElementById('myChart');
    const scrollWrapper = document.querySelector('.scroll-wrapper');
    scrollWrapper.addEventListener('scroll', onScroll);
    canvas.addEventListener('wheel', onWheel);

以下はChart.js関連の処理である。
説明は割愛する。

    // グラフの初期設定
    const data = {
        labels: labels.slice(0, maxDisplayDataLimit),
        datasets: [{
            label: 'Random Data',
            backgroundColor: 'rgba(75, 192, 192, 0.2)',
            borderColor: 'rgba(75, 192, 192, 1)',
            borderWidth: 1,
            data: randomData.slice(0, maxDisplayDataLimit),
        }]
    };

    const config = {
        type: 'line',
        data: data,
        options: {
            responsive: true,
            scales: {
                x: {
                    ticks: {
                        maxTicksLimit: maxDisplayDataLimit,
                    }
                },
                y: {
                    max: 100,
                    min: 0,
                    beginAtZero: true
                }
            }
        }
    };

    // グラフを描画
    const myChart = new Chart(
        canvas,
        config
    );

まとめ

canvasの最大寸法が超えるデータ量でもスクロール付きで描画できる方法を紹介した。
本記事で紹介した方法はPC向けであるため、スマートフォンに対応するには追加実装が必要となる。
また、パフォーマンス面を考慮せずに実装しているため、実際に利用する場合はイベントを間引くなどの改良が必要である。
本記事ではロジックの紹介がメインであるため、上記は考慮した実装は行っていない。

Discussion