notebook

都内でWEB系エンジニアやってます。

Chart.jsでラベルクリックで表示非表示をコントロールする

chart.jsをつかってグラフを書いていたのですがある日こんな要件が飛んできました

「凡例をクリックすると表示非表示できるけどラベルをクリックしても表示非表示切り替えたい」

!!!!!

!!!!!

ヒストグラムだと何言ってんだ?って感じの話ですがカテゴリスケールだと全然ある感じの要望だと思います

どれか1つが桁違いの数値だったりすると他の数値が分かりづらくなってしまうんですね

なので一番数値が大きいカテゴリのラベルをクリックしたらグラフを非表示にしての残りのカテゴリの数値を比べられるようにしようという感じです

f:id:swfz:20190113041255p:plain

Chart.jsのオプションだけでは実現できなそうだったので無理やり頑張った記録を残しておきます

やりたいこと

上記のグラフをもとに話をすると

  • 「A」をクリックしたらAの棒グラフと点が非表示になる
    • それに合わせて他のB、Cのグラフの面積が広くなる
  • もう一度「A」をクリックすると非表示になっていたグラフが見えるようになる

ようは「ラベルをクリックしたときにコールバックを呼びたい」です

これができればなんの問題もないのです

ただなぜかグラフ全体、凡例、グラフの要素にはコールバックが設定できるのにラベルだけはできないようでした。。。

代替案を調べる

まぁないなら他の方法で実現できないか調べるしかないのでググってみます

するとそれらしきissueは登録されていて色々話されています

How to listen for clicks on labels? · Issue #2802 · chartjs/Chart.js

github.com

簡単にはできなそうなことがわかりましたw

ただその中のリンクにPlunkerのリンクがありました

実際には動きませんでしたがコードが見れたのでだいたいやりたいことは把握できました

Plunker

plnkr.co

ということで実際に簡単に再現できそうなグラフを用意して作ってみました

f:id:swfz:20190113041338g:plain

環境

Angular: 7.1.3
Chart.js: 2.7.3
primeng: 7.0.3

どうやっているか

簡単な流れは下記

  • チャート全体のクリックイベントにコールバックを設定
    • Chart.helpers.getRelativePosition(clickEvent, chart)を使ってクリックされたポジションを取得
    • chart.scales['x-axis-0'].getValueForPixel(point) でX軸のラベルのインデックスを取得
    • chart.scales['Dataset1'].getValueForPixel(point) でY軸のクリックされた座標を取得
    • 上記からラベルがクリックされたか判断
    • ラベルがクリックされていた場合取得したX軸のインデックスを元にデータセットそれぞれの値をnullにする
    • 再びクリックされた場合はあらかじめ持っていたsnapshotからデータを指定し直す

実際のソースは下記

  • component.html
<p-chart
  #chartElem
  type="bar"
  [height]="500"
  [data]="chartData"
  [options]="chartOptions"
>
</p-chart>
  • component.ts
import { AfterViewInit, Component, OnInit, ViewChild } from '@angular/core';
import { UIChart } from 'primeng/chart';
import * as Chart from 'chart.js';
import * as _ from 'lodash';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent implements OnInit, AfterViewInit {
  @ViewChild('chartElem') chartElem: UIChart;
  chartData: any;
  chartOptions: any;
  data: any;
  snapshot: any;
  displayFlags: boolean[];

  ngOnInit(): void {
    this.chartData = {};
    this.chartOptions = {};
  }

  ngAfterViewInit() {
    this.displayFlags = [true, true, true];
    this.data = {
      dataset1: [1479, 102, 325],
      dataset2: [529496, 252436, 6375],
      dataset3: [6335002, 2828403, 35002]
    };
    this.snapshot = _.cloneDeep(this.data);
    this.setChartData();

    this.chartOptions = {
      scales: {
        xAxes: [
          {
            // id: 'data1',
            display: true,
            type: 'category',
            position: 'bottom',
            ticks: {
              autoSkip: false
            }
          }
        ],
        yAxes: [
          {
            id: 'Dataset1',
            type: 'linear',
            position: 'left',
            scaleLabel: {
              display: true,
              labelString: 'Dataset1'
            },
            gridLines: {
              drawOnChartArea: true
            },
            stacked: true,
            ticks: {
              display: true,
              beginAtZero: true
            }
          },
          {
            id: 'Dataset2',
            type: 'linear',
            position: 'right',
            scaleLabel: {
              display: true,
              labelString: 'Dataset2'
            },
            gridLines: {
              drawOnChartArea: true
            },
            stacked: true,
            ticks: {
              display: true,
              beginAtZero: true
            }
          },
          {
            id: 'Dataset3',
            type: 'linear',
            position: 'right',
            scaleLabel: {
              display: false,
              labelString: 'Dataset3'
            },
            gridLines: {
              drawOnChartArea: false
            },
            stacked: true,
            ticks: {
              display: false,
              beginAtZero: true
            }
          }
        ]
      },
      onClick: (event, activeElems) => {
        // ラベルをクリックしたらデータを一時的に表示しないようにする処理
        // ラベルをクリックしたかどうかの判定にY軸の値を取得して判定しているので
        // 前提として凡例などが上にある必要がある
        const clickPoint = Chart.helpers.getRelativePosition(
          event,
          this.chartElem.chart
        );

        // x-axis-0は options.scales.xAxesの中のIDの値と同等、何も指定がない場合は`x-axis-${i}`がキーとなる
        const clickX = this.chartElem.chart.scales['x-axis-0'].getValueForPixel(
          clickPoint.x
        );

        // 既存のデータセットの中でどれでも良いので適当なものをキーとする
        const clickY = this.chartElem.chart.scales['Dataset1'].getValueForPixel(
          clickPoint.y
        );

        if (clickY < 0) {
          this.reloadChartData(clickX, !this.displayFlags[clickX]);
        }
      }
    };
  }

  private setChartData() {
    this.chartData = {
      labels: ['A', 'B', 'C'],
      datasets: [
        {
          type: 'line',
          label: 'Dataset1',
          borderColor: '#3399FF',
          backgroundColor: '#3399FF',
          yAxisID: 'Dataset1',
          pointStyle: 'circle',
          borderDash: [0, 10],
          fill: false,
          data: this.data.dataset1
        },
        {
          type: 'bar',
          label: 'Dataset2',
          borderColor: '#99FF33',
          borderWidth: 2,
          yAxisID: 'Dataset2',
          pointStyle: 'circle',
          fill: false,
          data: this.data.dataset2
        },
        {
          type: 'bar',
          label: 'Dataset3',
          borderColor: '#FF9933',
          borderWidth: 2,
          yAxisID: 'Dataset3',
          pointStyle: 'circle',
          fill: false,
          data: this.data.dataset3
        }
      ]
    };
  }

  private reloadChartData(x?: number, flag?: boolean) {
    if (x !== undefined && flag !== undefined) {
      if (flag) {
        this.data.dataset1[x] = this.snapshot.dataset1[x];
        this.data.dataset2[x] = this.snapshot.dataset2[x];
        this.data.dataset3[x] = this.snapshot.dataset3[x];
      } else {
        this.data.dataset1[x] = null;
        this.data.dataset2[x] = null;
        this.data.dataset3[x] = null;
      }
      this.displayFlags[x] = flag;
    }
    this.chartElem.chart.chart.update(this.chartOptions);
  }
}

色々雑ですがいったんこんな感じで実現ができました

一応実装はできたものの、カテゴリ自体が多い+ラベルの文字数が多いとラベルが敷き詰められて斜めに描画されるのでクリックした箇所と非表示になるグラフで差異が発生し、意図してないグラフが非表示になってしまうというという現象が残っています。。。

これ以上詳細にクリックされた箇所がラベルか特定するのが難しそうなのでいったんこの点に関しては目をつむってもらうようにします。。。

  • demo

ChartjsSample

swfz.github.io

  • リポジトリ

swfz/chartjs-sample: with Angular

github.com

まとめ

ということでなんとかラベルを判定してグラフ表示を切り替える実装ができるようになりました

大分Chart.jsと仲良くなれた気がします。。。