📈

chart.jsでガントチャートを作成(Vue3/TS)

2023/04/21に公開

chart.jsとは

chart.jsとは、以前私の記事でもご紹介させていただきましたが、簡単にレーダーチャートや折れ線グラフ、棒グラフなどを実装できるライブラリです。

https://www.chartjs.org/docs/latest/

今回は単にグラフを作るのではなく、日付と連動して動くガントチャートを作成しようと思い作ってみましたので、こちらに流れを書き記していきたいと思います。

完成イメージ



⇨バーにhoverしたら描画する吹き出し

今回はVue3+TSで作成しました。

事前準備

・VueDatePicker(https://vue3datepicker.com/)
・chart.js(https://www.chartjs.org/)
・chartjs-adapter-date-fns(https://github.com/chartjs/chartjs-adapter-date-fns)

上記4つをnpmやyarnでinstallしておいてください!

pnpm install @vuepic/vue-datepicker
pnpm install chart.js
pnpm install chartjs-adapter-date-fns

作成

全部Gantt.vueというファイルの中で実行しますので、縦長になると思います。
私の書き方が煩雑かもしれないので、もしも何かあればご指摘よろしくお願いいたします。

またtsでの型付けなのですが、少し間違っているかもしれません。
一応errorもはかず、画面上で動いているものを提供していますので、その辺りご容赦くださいませ。

下記Youtubeでも説明がありますのでご参照ください。
https://www.youtube.com/watch?v=zAOls0NC8xI&list=PLc1g3vwxhg1Xl9QVaLnLUaGzuWH-zEGZD

では早速書いていきます。

コメントで要所要所説明を入れてます。(重要な部分だけですが、、、。)
ですが、実際に触ってみた方が早いかもです!!
cssやデザインなどはお好みでいじってみてください。

Gantt
<script setup lang="ts">
import { ref, onMounted, watch } from "vue";
// VueDatePickerをimport
import VueDatePicker from '@vuepic/vue-datepicker';
import '@vuepic/vue-datepicker/dist/main.css'
// chart.jsをimport、型宣言で使うものもあります。
import {
  Chart,
  registerables,
  type ChartConfigurationCustomTypesPerDataset,
  type ChartConfiguration,
  type TooltipItem,
} from 'chart.js';
import 'chartjs-adapter-date-fns';


/**************************
* Type(todayLine) 
***************************/
type TodayLine = {
  id: string,
  afterDatasetDraw: (chart: Chart) => void;
};


/****************************************************
* ここで上でimportしたのもを使いますと宣言します。
*****************************************************/
Chart.register(...registerables)


/****************************************************
* datepickerで絞り込みを行う際に使いたいデータを宣言
*****************************************************/
let myChartDataChangeFlag = ref<boolean>(false)
let myChart = ref()
let thisYear = ref<number>(new Date().getFullYear())
let thisMonth = ref<number>(new Date().getMonth() + 1)
let thisMonthFormatted = ref<string>(('0' + thisMonth.value).slice(-2))
let disabledFlag = ref<boolean>(false)


/****************************************************
* 'Today'で今日はどこなのかを描画する。(pluginで使ってます。)
*****************************************************/
const todayLine: TodayLine = {
  id: 'todayLine',
  afterDatasetDraw(chart) {

    const { ctx, data, scales:{x,y}, chartArea } = chart;
    let canvas = document.getElementById("myChart") as HTMLCanvasElement;
    let ctxSec = canvas.getContext('2d')

    const now = Date.now(); // UNIXタイムスタンプを取得する
    const xPixel = x.getPixelForValue(now); // UNIXタイムスタンプを元にx座標を計算する

    ctx.save();

    if(xPixel >= chartArea.left && xPixel <= chartArea.right){
      //ここで縦線をひいてます。
      ctx.beginPath();
      ctx.lineWidth = 2;
      ctx.strokeStyle = 'rgba(255, 26, 104, 1)';
      ctx.setLineDash([4,4]);
      ctx.moveTo(xPixel, chartArea.top);
      ctx.lineTo(xPixel,chartArea.bottom);
      ctx.stroke();
      ctx.restore();
      ctx.setLineDash([]);
   
      //ここで下向きの三角形を作ってます。
      ctx.beginPath();
      ctx.lineWidth = 1;
      ctx.strokeStyle = 'rgba(255, 26, 104, 1)';
      ctx.fillStyle = 'rgba(255, 26, 104, 1)';
      ctx.moveTo(xPixel, chartArea.top + 3)
      ctx.lineTo(xPixel - 6, chartArea.top - 6);
      ctx.lineTo(xPixel + 6, chartArea.top - 6);
      ctx.closePath();
      ctx.stroke();
      ctx.fill();
      ctx.restore();

            // Todayという文字を描画します。
      ctx.font = 'bold 12px sans-serif';
      ctx.fillStyle = 'rgba(255, 26, 104, 1)';
      ctx.textAlign = 'center';
      if (ctxSec) ctxSec.fillText("Today",xPixel, chartArea.bottom + 15);
    }
  }
}


/****************************************************
* ガントチャートで使うデータを定義
*****************************************************/
const myChartData: ChartConfiguration<"bar", { x: [string, string]; y: string; z: string; }[], unknown> | ChartConfigurationCustomTypesPerDataset<"bar", { x: [string, string]; y: string; z: string; }[], unknown> = {
  type: 'bar',
  data: {
    datasets: [
      {
        backgroundColor: [
	  // ここを増やしていけばバーの色のバリエーションが増えます。
          'rgba(255, 99, 132, 0.2)',
        ],
        borderColor: [
	  // ここを増やしていけばバーの色のバリエーションが増えます。
          'rgb(255, 99, 132)',
        ],
        borderWidth: 1,
        barPercentage: 0.4,
        borderSkipped: false,
        borderRadius: 5,
        data:[
         {x:['2023-02-01', '2023-06-01'] , y: 'バージョンβ', z: 'hoverしたときに出てくる文字を入力'},
        ],
      },
    ]
  },
  options: {
    // レスポンシブにしています。アスペクト比なども揃えています。
    responsive: true,
    maintainAspectRatio: false,
    indexAxis:"y",
    layout: {
      padding: {
        left: 10,
        bottom: 20
      }
    },
    scales: {
      x: {
        offset: false,
        position: "top",
	// 何月何日が始まりですよ〜と定義してます。
        min:`${thisYear.value}-${thisMonthFormatted.value}-01`,
        type: 'time',
        time: {
          unit:'month',
          displayFormats: {
            year: 'yyyy',
            month: 'yyyy/MM',
            week: 'yyyy/MM/dd',
          }
        },
        ticks: {
	  // 上に出てくる日付をセンターに寄せてます。
          align: 'center',
        },
        grid: {
          drawTicks: true,
          drawOnChartArea: true
        }
      },
      y: {
        beginAtZero: true,
        ticks: {
          font: {
            weight: '500'
          }
        },
      }
    },
    plugins: {
      legend: {
        display: false,
        position: 'bottom',
        labels: {
          color: '#000'
        }
      },
      tooltip: {
        // hoberしたときに出てくる吹き出しを定義してます。
        displayColors:false,
        yAlign: 'bottom',
        enabled: true,
        callbacks : {
          label: (data: TooltipItem<'bar'>) => {
            const raw = data.raw as { z: string };
            return `${raw?.z}` || '';
          },
          title: (ctx: TooltipItem<'bar'>[]) => {
            const startDate = new Date((ctx[0].raw as {x: [string | number | Date, string | number | Date]}).x[0]);
            const endDate = new Date((ctx[0].raw as {x: [string | number | Date, string | number | Date]}).x[1]);
            const formattedStartDate = startDate.toLocaleString([],{
              year:'numeric',
              month:'short',
              day:'numeric',
              // hour12:true,
            })
            const formattedEndDate = endDate.toLocaleString([],{
              year:'numeric',
              month:'short',
              day:'numeric',
              // hour12:true
            })
	    // 最終的に出てくる文字をreturnしてます。
            return `期間 : ${formattedStartDate} - ${formattedEndDate}`
          }
        }
      },
    }
  },
  // 先ほど上で定義した本日の線をpluginとして利用
  plugins: [todayLine]
}


/****************************************************
* VueDatePickerで日付を確定した時の処理
*****************************************************/
const chartFilter = (date: { year: number; month: number; }) => {
  if(date){
    const year = date.year
    const month = date.month + 1
    let startDate;

        // 2023/04 とか 2023/12とかをうまく描画するため
    if(month.toString().length < 2){
      startDate = `${year}-0${month}`
    } else {
      startDate = `${year}-${month}`
    }

    if(myChartData.options && myChartData.options.scales && myChartData.options.scales.x) myChartData.options.scales.x.min = startDate;
    myChartDataChangeFlag.value = !myChartDataChangeFlag.value
  } else {
    return
  }
}


/****************************************************
* radioボタンを押した時の処理
*****************************************************/
const changeScale = (e: Event) => {
  if (e.target instanceof HTMLInputElement) {
    const scale = e.target.value;
    // @ts-ignore
    // ignoreしてしまいました。。。泣
    if(myChartData.options && myChartData.options.scales && myChartData.options.scales.x) myChartData.options.scales.x.time.unit = scale;
  }
  myChartDataChangeFlag.value = !myChartDataChangeFlag.value
}

/****************************************************
* VueDatePickerのv-modelで見てるもの(公式を見たらわかるはずです!)
*****************************************************/
const month = ref({
  month: new Date().getMonth(),
  year: new Date().getFullYear(),
  day: new Date().getDate()
});

const format = (date: Date) => {
  const month = date.getMonth() + 1;
  const year = date.getFullYear();

  if(month.toString().length < 2){
    return `${year}/0${month}`;
  } else {
    return `${year}/${month}`;
  }

}


/****************************************************
* バーの本数によって高さを自動調節してくれる関数
*****************************************************/
let ganttHeight = ref<number>(300);

// ここの65はよしなに変えてください。
const ganttHeightAdjustment = () => {
  ganttHeight.value = myChartData.data.datasets[0].data.length * 65
}



/****************************************************
 * chart.jsを描画、スケールが変わったら再描画する処理
 *****************************************************/
onMounted(() => {

  ganttHeightAdjustment()

  disabledFlag.value = !disabledFlag.value

  let ctx: HTMLCanvasElement | null = document.getElementById("myChart") as HTMLCanvasElement;
  if (ctx) myChart.value = new Chart(ctx, myChartData);

  setTimeout(() => {
    disabledFlag.value = !disabledFlag.value
  }, 1000);

})

watch((myChartDataChangeFlag),() => {

  disabledFlag.value = !disabledFlag.value

  // ここのデストロイをしないと再描画できないので注意!!
  myChart.value.destroy();

  let ctx: HTMLCanvasElement | null = document.getElementById("myChart") as HTMLCanvasElement;
  if (ctx) myChart.value = new Chart(ctx, myChartData);

  setTimeout(() => {
    disabledFlag.value = !disabledFlag.value
  }, 1000);

})
</script>
Gantt
<template>
  <div class="ganttChartArea">
    <!-- 日付の絞り込みのゾーン VueDatePickerを使ってます。-->
    <div class="selectZone">
      <!-- VueDatePicker -->
      <VueDatePicker
        class="datePicker"
        placeholder="日付を選択"
        locale="ja"
        v-model="month"
        :format="format"
        :enable-time-picker="false"
        :day-class="getDayClass"
        week-start="0"
        :month-change-on-scroll="false"
        hide-offset-dates
        no-today
        :disabled="disabledFlag"
        @update:model-value="chartFilter"
        monthPicker
    />
      <!-- ここがradioボタン -->
      <div class="changeScale">
        <label @change="changeScale" class="changeScaleBtn">
          <input type="radio" name="changeScaleBtn" value="year" :disabled="disabledFlag">
          <p>Year</p>
        </label>
        <label @change="changeScale" class="changeScaleBtn">
          <input type="radio" name="changeScaleBtn" value="month" checked :disabled="disabledFlag">
          <p>Month</p>
        </label>
        <label @change="changeScale" class="changeScaleBtn">
          <input type="radio" name="changeScaleBtn" value="week" :disabled="disabledFlag">
          <p>Week</p>
        </label>
      </div>
    </div>
    <div
      class="gantt"
      :style="{
        height:ganttHeight + 'px',
      }"
    >
      <div class="chart">
        <!-- ここにガントチャートを描画する -->
        <canvas id="myChart"></canvas>
      </div>
    </div>
  </div>
</template>

上記コードについて

勉強しながら進んでいくと設定とかが煩雑になってしまったので、そこは反省しなくてはいけませんね。。。笑
ですが、有料のチャート系のライブラリと比較してみましたが、それぞれに短所や長所があって面白いなと感じました。

今回はvueやJS/TSの勉強ということをテーマにしていたり、canvasの仕組みなども知りたかったので、chart.jsで制作してみました。reactiveなデータの扱いやclick時の挙動や、それに伴ったタイミングを確認する作業などもあってぶっちゃけ大変でした。

また本記事には書いてないのですが、管理の観点から行くと、エンジニアではない方でも更新できるようにするべきだと思いまして、CSVやCMSを利用して管理できるようにもしました。
その時、APIを叩いたりするのですが、そのタイミングも描画との兼ね合いを考えて作成しました。

これから

最近ようやく一人でvue/tsを駆使して作りたいものを形にできるようになってきました。
これからもコーディングを楽しみつつ、アウトプットしていけたらと思います。

Discussion