【Chart.js 3.x】駅伝のタイムを積み上げ棒グラフで視覚化する

サムネイル

駅伝と言っても今回指しているのはファイナルファンタジー(以下FF) RTA駅伝の方。
4チームが競争するRTA駅伝のクリアタイムを視覚化したかったのでJavaScriptのライブラリ「Chart.js」とそのプラグインで組んでみた。

漫画を描くのに疲れて現実逃避でプログラミング。

※素人なので最善を保証するものではないことをご理解ください。

▼完成イメージ(Fig1)

積み上げ棒グラフ完成図
Fig1. 積み上げ棒グラフ完成図

作業日

2022.9.18~2022.9.21

環境・バージョン

Chart.js: v3.9.1
chartjs-plugin-datalabels: v2.1.0

Chart.js _ Open source HTML5 Charts for your website

chartjs-plugin-datalabels

このプログラムに含まれている処理

  • 時分秒を秒数に、秒数を時分秒に変換する
  • ネストしたforループでオブジェクトからデータを取り出す
  • (Chart.js) ツールチップに表示される値を加工する
  • (Chart.js) 目盛りの単位と数値を変更する
  • (chartjs-plugin-datalabels) 棒グラフ上に値を表示する

なぜプログラムを組んだか

FF RTA駅伝のクリアタイムを視覚化したかった。
夏(先月)にRTAのイベントが開催されていることを知り、その解説と実況が面白くてハマり、YoutubeのRTAinJapanチャンネルの中に「第4回FFRTA駅伝対決」のアーカイブを見つけたのがきっかけ。
攻略本で認知されたものよりさらに洗練された攻略法が新鮮だった。

TwitchにはFF駅伝のチャンネルがあり、第5回、第6回のアーカイブを観ている間に順位の変動を視覚化したいと思った。

最初はHTML5やSVGでやるものかと思っていたが、グラフの描画に関してはChart.jsのライブラリを使うページばかりヒットしたので頼ることにした。
(ライブラリを使い慣れていないので、便利だとしてもどこまで依存していいのか不安があり、消極的ではあった。しかし、すべて自作することも無謀だと自覚しているので使うことにした。)

プログラミング素人かつ、JavaScriptに触ったのは7年ぶりくらいだが、調べながらどうにかできた。

第4回 FINAL FANTASY RTA 駅伝対決 – YouTube

ffekiden – Twitch

プログラム解説

フォルダ構成

HTMLファイルが一つと、JavaScriptファイルが一つ。
CSSは不使用でもグラフィカルなグラフを生成できる。

作業用フォルダ
├ index.html
└ script.js

Chart.jsの使い方

基本的な使い方は多くのページで解説されているので見比べて補完してほしい。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>積み上げ棒グラフ</title>
    <script src="https://cdn.jsdelivr.net/npm/chart.js@3.7.1"></script>
    <script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2"></script>
</head>
<body>
    <div>
        <canvas id="canvas" width="800" height="500">お使いのブラウザはcanvasに対応していません。</canvas>
    </div>
</body>
<script type="text/javascript" src="script.js"></script>
</html>

素人の手軽な開発環境なので、ローカルのhtmlファイルでライブラリをCDNから読み込んでいる。

(chartjs-plugin-datalabelsは現時点で2.1.0がリリースされているが、ダウンロードするコードの例は2.0.0になっている。2.1.0を利用するために「@2」で最新版を読み込むようにした。)

グラフを描画するエリアはcanvasタグとプロパティで実装する。
JavaScriptファイルはbodyタグの後で読み込んでいる。

シンプルなグラフを作成するとき、canvasタグが持つグラフを描画するための機能とデータをライブラリに渡している(Fig2)。

スライド_Chart.jsに渡すデータ構造
Fig2. Chart.jsに渡すデータ構造

データとは、例えば「5人の英語科目の点数」。
「5人の名前」や「英語科目」がラベルで、「5人の点数」は配列で表現できる。

他にはグラフ表現方法について、オプションで指定することになる。
グラフのタイトルや、目盛りの間隔を変更することができる。

決められたデータ構造に従う

上に書いたように、typeとdataとoptionsを持つオブジェクトを渡せばグラフを描画することができる。
(chartjs-plugin-datalabelsを利用するならpluginsも。)
逆に言えば、データ構造が正しくないと機能しない(と思う)。

よって、これから自分が期待する結果を実現するために、加工したいデータを決められたデータ構造に従って用意することになる。

たった4日だが、初めてライブラリを真剣に触ってみて、使い方を理解できた気がする。
他のライブラリも決められたデータ構造を渡すことで機能させられるんだろう。

【余談】今のJavaScriptは変数の宣言にvarを使わない
今回一番驚いたのはコレ。
今ではletかconstを使用する。

constは定数に用いるのだが、「値を変更できないわけではない」という混乱を経験したので、参考になるページを提示する。

JavaScriptの定数(const)は「変更できない」わけではない。 – Qiita

グラフ化したいデータの準備

今回使用するデータは視聴中の第6回FFRTA駅伝対決の記録を使用する。
FFRTA駅伝対決のWikiには過去の大会の記録が載っているので、そこから拝借することにする。

FINAL FANTASY RTA 駅伝対決 wiki – atwiki(アットウィキ)

グラフには「4つのチーム」が「FF作品ごと」に「クリアしたタイム」をデータとして利用する。
これらのデータをオブジェクトにすると以下のようになる。

/*==========
    Setup
==========*/
const labels = ["Aチーム", "Bチーム", "Cチーム", "Dチーム"];

const data = {
    labels: labels, //["Aチーム", "Bチーム", "Cチーム", "Dチーム"]
    datasets: [
        {
            label: "FF4",
            data: [
                { hour: 4, min: 6, sec: 21 },
                { hour: 3, min: 57, sec: 21 },
                { hour: 4, min: 26, sec: 6 },
                { hour: 4, min: 11, sec: 26 }
            ],
            backgroundColor: ['#6CBAD8']
        },{
            label: "FF5",
            data: [
                { hour: 5, min: 11, sec: 9,},
                { hour: 4, min: 19, sec: 42,},
                { hour: 4, min: 14, sec: 47,},
                { hour: 4, min: 48, sec: 46,}
            ],
            backgroundColor: ['#68CFC3']
        },{
            label: "FF6",
            data: [
                { hour: 5, min: 21, sec: 57 },
                { hour: 5, min: 49, sec: 25 },
                { hour: 5, min: 56, sec: 38 },
                { hour: 6, min: 25, sec: 26 }
            ],
            backgroundColor: ['#81D674']
        },{
            label: "FF9",
            data: [
                { hour: 9, min: 53, sec: 14 },
                { hour: 9, min: 45, sec: 34 },
                { hour: 10, min: 0, sec: 20 },
                { hour: 9, min: 56, sec: 50 }
            ],
            backgroundColor: ['#EBF182']
        },{
            label: "FF12",
            data: [
                { hour: 8, min: 29, sec: 40 },
                { hour: 6, min: 16, sec: 7 },
                { hour: 6, min: 29, sec: 44 },
                { hour: 6, min: 33, sec: 39 }
            ],
            backgroundColor: ['#FBE481']
        },{
            label: "FF15",
            data: [
                { hour: 4, min: 22, sec: 40 },
                { hour: 4, min: 18, sec: 27 },
                { hour: 4, min: 18, sec: 29 },
                { hour: 4, min: 10, sec: 35 }
            ],
            backgroundColor: ['#EDA184']
        }
    ]
};

【余談】JSON形式でデータを渡したかった
過去にPythonでJSONファイルを扱った経験から、「データを渡すならJSONっしょ」と安易に考えていたのだが、ローカルのJavaScriptファイルでJSONファイルを読み込ませようとするとブラウザ側でエラーが発生する。

これはセキュリティの問題のようで、ローカルでもサーバを立てるか、オプションをつけてブラウザを起動することで回避できるらしい(CORSで検索すればヒットする)。

これらは手軽ではなかったし、JSONもJavaScriptの表記に似せているんだから、グラフの完成を優先してデータをJavaScriptファイルに内包することにした。
理想は処理部分とデータ(入力と保持)を分けること。

時分秒を比較可能にする

ペーパーテストの点数や身長測定の結果のように比較可能な数値なら都合がいいのだが、時間(タイム)はそうはいかない。

時間も分も秒も、重みがまるで異なる。
このデータを比較可能に、グラフで表現可能にするには、「秒数に変換する」ことを思いついた。

コンピュータ側では秒数で処理してもらい、人間が見る箇所は時分秒で表示してくれれば出来のいいグラフにできそうだ。

【余談】これ以外思いつかなかった
もしかしたら、時分秒のままグラフにする方法があるかもしれない。
でも見つからなかった。

「時間の変換」となるとExcelの話題がヒットして、プログラムとして探すと「C言語で入力した時間を秒数に変換する、秒数を時間に変換する」くらいのものしかなかった。

(ストップウォッチやタイマーでは1970年からの経過時間をミリ秒で扱うが、それは遠回りだと思い除外。)
変換する処理はC言語の記事が今回役に立った。

【C言語】時分秒を秒に変換するプログラムの作り方

さて、用意したデータ構造にはクリアタイムが時分秒で入力されている。
データ構造は決められていて、同じ箇所(data.datasets[i].data[j])に秒数を置かないとグラフに反映できない。

今回は置き換え(オブジェクトの更新)ができたのでこのまま進めるが(constで宣言しても更新できる話)、完成後に思いついたのは「秒数が入るオブジェクトは空にしておき、計算に使う時分秒のデータを別オブジェクトから引っ張って加工し、変換した結果を挿入する」方法。
決められたデータ構造に従うために、用意したデータが置き換わるのは良くない方法だと自覚している。

/*==========
    Process
==========*/
//★データ加工:RTAのタイムを取得、秒数に変換
for( let i in data.datasets ){
    for( let j in data.datasets[i].data ){
        if( data.datasets[i].data.hasOwnProperty(j) ) {
            let h = data.datasets[i].data[j].hour;
            let m = data.datasets[i].data[j].min;
            let s = data.datasets[i].data[j].sec;

            //秒数に変換し、配列を更新(※オブジェクトは参照型なのでconstで宣言されても変更できる?)
            data.datasets[i].data[j] = h * 3600 + m * 60 + s;
        }
    }
};

▼ネストしたforループの挙動(Fig3)
▼時分秒のデータを秒数に変換後のオブジェクトのデバッグ表示(Fig4)

スライド_ネストしたforループ
Fig3. ネストしたforループ
秒数に変換後のオブジェクトのデバッグ表示
Fig4. 秒数に変換後のオブジェクトのデバッグ表示

シンプルなグラフの完成

クリアタイムを秒数に加工することでグラフの作成が可能になった。
積み上げグラフを有効にしたり、色をわけたり、グラフを横方向に回転させる以外はデフォルトの設定で描画されている。
その結果、まだグラフ上に値は表示されていないし、軸の目盛りも秒数を基に自動で表示されている(Fig5)。

最低限の設定でグラフを描画
Fig5. 最低限の設定でグラフを描画

ひとまずは視覚化が実現して、「どの作品にどの程度時間がかかったか」、「どのチームがどのあたりで追い越したか・追い越されたか」が分かるようになった。

残った課題は3つ挙げられる。
一つは、軸の目盛り(単位)を「時間」にすること。
2万秒で刻まれてもわかりにくい。

二つめは、棒グラフ上に値を時分秒で表示すること。

三つめは、ツールチップに表示される値を時分秒にすること。
棒グラフ上に値を表示させればツールチップは不要なのだが、チェックポイント通過タイムを描画する際にはほしいので手を出した。

軸の目盛りを加工する

軸の目盛りに干渉するには、「options.scales.x.ticks」にコードを追加する。
(なお、軸を90度回転させているので、横方向がX軸になっている。縦のグラフや終え線グラフのサンプルではY軸にあたる。)

目盛りは1時間刻みにしたいのでstepsizeプロパティには3600(数値)を与える。
目盛りには「時間」という単位を付与したいので、callbackプロパティに関数内で処理した結果を渡している。
デフォルトの自動計算では20,000秒刻みだった目盛りを3,600で割った整数に文字列「時間」を結合する。

options: {
    scales: {
        x: {
            ticks: {    //目盛りラベル(単位)を時間に
                stepSize: 3600,
                callback: function( value, index, ticks ){
                    return value / 3600 + '時間';
                }
            }
        },
        y: {
            stacked: true,
            title: {
                display: true,
                text: "Y軸"     //確認用
            }
        }
    },
}

▼目盛りを加工した結果(Fig6)

目盛りの表示を加工した結果
Fig6. 目盛りの表示を加工した結果

グラフ上に値を表示する

グラフ上に値を表示するにはプラグインである「chartjs-plugin-datalabels」を利用する必要があった。
プラグインの機能を利用するにはライブラリに渡すデータ構造にキー「plugins」と値「[ ChartDataLabels ]」を記述する必要がある。

コードを記述する階層は「options.plugins.datalabels」になる。
(模索しながらの開発だったのでdatalabelsを別オブジェクトとして記述している。)

グラフが持っている秒数のデータを時分秒にそれぞれ変換し、文字列と結合した結果をformatterプロパティに返している。

割り算と文字列の結合を1行で行えるので変数を3つ用意する必要はなかった。
また、割り算で浮動小数点が発生するのでMath.floor関数で整数に丸めた。
(切り上げと切り下げのどちらが適切かわかっていないが、今回は元のタイムと同じ結果が出たのでヨシッ rァ

chart.js で各値を常に表示する方法 _ KumaTechLab

//棒グラフ上に値を表示(時分秒に変換)※chartjs-plugin-datalabels
const datalabels = {
    formatter: function( value ) {
        let clearTime = ( Math.floor( value / 3600 )) + "時間"
                    + ( Math.floor( value % 3600 / 60 )) + "分"
                    + ( Math.floor( value % 60 )) + "秒";
        return clearTime;
    }
};

▼グラフ上に値を(時分秒)で表示した結果(Fig7)

グラフ上に値を表示した結果
Fig7. グラフ上に値を表示した結果

(一度秒数に変換したデータをもう一度時分秒に変換するのは、誤差が生じる可能性も考えて推奨される方法ではないと思う。
元のデータを引っ張ってくることができれば確実なのだが、ここで表示する値はグラフが持っている値なので仕方がないのだろう。

時分秒と秒数のデータを別オブジェクトで同じデータ構造に保持すれば、同じ配列番号で欲しい方のデータを引っ張ることができるかもしれない。)

ツールチップに表示される値を加工する

ツールチップはカーソルが乗っているグラフの値を表示してくれるが、時分秒で表示する目的は同じでもやり方に違いがあった。

ツールチップはタイトル(ここでは「Aチーム」など)と、ラベル(「FF4」など)、そして値(グラフが持つ秒数)が表示されているが、上半分のタイトルと下半分のラベル+値に分かれていることがわかった。

前述したグラフ上に値を表示する場合は関数に値が渡されていたので時分秒に変換するだけでよかったが、ツールチップに干渉する場合、関数にはオブジェクトが渡されているので、そこからラベル情報と値を取り出して結合することになる。

ラベルを取り出すのは難しくないが、値を取り出すには正しい記述をする必要がある。
バージョンが古い解説では「xLabel(もしくはyLabel)」プロパティを利用するコードが見られるが、これはすでに廃止されている
詳しくは公式ドキュメントの3.x移行ガイドを参照。

Tooltip

– xLabel and yLabel were removed. Please use label and formattedValue

3.x Migration Guide _ Chart.js

これを解決するには公式のドキュメントのサンプルと、console.log()でデバッグして得られたcontextオブジェクトが役に立った。
欲しい数値がparsedプロパティの中にあることを見つけたからだ(Fig8)。
公式のサンプルでも「parsed.x(もしくはparsed.y)」で値を取得しているので裏付けは取れた。

tooltip: {
    callbacks: {
        label: function(context) {
            let label = context.dataset.label || '';
            if (label) {
                label += ': ';
            }
            if (context.parsed.y !== null) {
                label += new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(context.parsed.y);
            }
            return label;
        }
    }
}
https://www.chartjs.org/docs/latest/configuration/tooltip.html
出力したcontextオブジェクト
Fig8. 出力したcontextオブジェクト

あとはこの値を割り算かつ小数点切り下げを行い、ラベルの文字列と結合して返せばいい。
これで実現したい結果はほぼ得られた。

//ツールチップに表示される値を時分秒に変換(※xLabel、yLabelは廃止!)
const tooltip = {
    callbacks: {
        label: function( context ){
            let label = context.dataset.label || '';
            if (label) {
                label += ': ';
            }
            let xValue = context.parsed.x;
            label += Math.floor( xValue / 3600 ) + "時間"
                + Math.floor( xValue % 3600 / 60 ) + "分"
                + Math.floor( xValue % 60 ) + "秒";
            return label ;
        }
    }
};

あとがき

プログラムの課題

実用を考えると、まだまだ課題はあると思う。
今回はjsファイル内にデータを持たせたが、処理部分とデータは分けたいし、コロン記号を含んだ文字列から時分秒のデータを取り出せれば、いちいち「hour: 1, min: 23, sec: 45」みたいに手間のかかる入力をせずに済む。

欲をいえば、完成イメージのようにチェックポイント(CP)のタイムを折れ線グラフで複合すればチェックポイント通過の順位を視覚化することができると思うのだが、
①データ(入力)量が恐ろしく増える
②ライブラリに渡すデータ構造に縛られる
③その他グラフ描画の設定の調整
が必要になる。

中でも①のデータ入力量が増えるのが大変なので中断した。
クリアタイムだけなら6作品×4チーム×時・分・秒の入力回数(6x4x3=72)で済むが、チェックポイントは各作品に4~8個ある。
(第6回 FF RTA駅伝にはthe end以外のチェックポイントが27個ある。27x4x3は?)

②は、積み上げ棒グラフと折れ線グラフを複合する都合上、「どのデータがどのグラフに使用されるか」をIDを付ける必要がある。
また、データ構造上2種類のデータは隣り合って存在するため、記録タイムを秒数に変換する処理を修正する必要があった。(ネストしたforループの処理)

③は具体的に言うと「棒グラフのツールチップは非表示にして、折れ線グラフだけツールチップを表示する」とか、可能かどうかわからないからである。

想定された使い方からズレているとネットで検索するのも大変。

感想

Chart.jsを扱う記事は魔境であると感じた。
バージョン2系と3系が入り混じり(ひどいものはバージョンの表記もない)、最新1年以内のページを検索してもすでに廃止されたコードを書いていて当然エラーが出るとか。
xLabel,yLabelは廃止されました」(大事なことなので2回言いました)。

また、「軸」が英語で「Axis」なのだが(複数形がAxes)、Chart.jsの解説ではこれが誤字なのか正解なのか混乱する場合があった。
Chart.jsではX軸・Y軸を「xAxis」「yAxis」と書くのが正しいと思うのだが、「yAxes」という記述もあり、「Y軸を指しているのに複数形?」と調べる手間が増えたりした。

それと、個人的にChart.jsのドキュメントがわかりづらかった。
サンプルのコードは載っているが、図がないため「そのコードでどこが変わったのか」を推測せざるをえなかった。
個人が書き残したページと比較しながら試すことで完成させることができた。

プログラミングは「楽しい」。絵は……

プログラミングをしていて「楽しい」と思えたから、その日取り組むのも早かった。
分からないことを翌日にまたぎながら、ライブラリを使って4日で済んだことも大きいだろう。

でも絵は楽しいと思えない状態にあり、現実逃避から戻らなければならない。

今回プログラミングが楽しいと思えた理由を絵にも転用できればマシになるだろか……

付録

さいごに、configオブジェクトのコードを添付する。
(ページが長くなるけど許してヒヤシンス)

別オブジェクトのコードと組み合わせること。

/*==========
    Config
==========*/
const config = {
    plugins: [ ChartDataLabels ],   //※chartjs-plugin-datalabelsの利用
    type: 'bar',
    data: data,                     //別オブジェクトで宣言
    options: {
        plugins: {
            datalabels: datalabels, //別オブジェクトで宣言
            tooltip: tooltip,       //別オブジェクトで宣言
            title: {
                display: true,
                text: "第6回 FF RTA駅伝 クリアタイムの視覚化"
            }
        },
        indexAxis: 'y', //グラフを横方向へ
        scales: {
            x: {
                stacked: true,  //積み上げ棒グラフON
                title: {
                    display: true,
                    text: "経過時間"
                },
                ticks: {    //目盛りラベル(単位)を時間に
                    stepSize: 3600,
                    callback: function( value, index, ticks ){
                        return value / 3600 + '時間';
                    }
                }
            },
            y: {
                stacked: true,
                title: {
                    display: true,
                    text: "Y軸"     //確認用
                }
            }
        },
        responsive: false,  //レスポンシブ
    }
};

/*==========
    Output
==========*/
//★出力:グラフを描画
const ctx = document.getElementById('canvas').getContext('2d');
const chart = new Chart(ctx, config);