スクラッチからVue.jsで作る自作ガントチャート

2021年6月10日 更新
vue-js-calendar-from-scratch

Vue.js を利用してスクラッチからガントチャートを作成する方法を解説しています。JavaScript のガントチャートを検索するとライブラリの利用方法を解説している記事がほとんどで作成方法を公開している記事を見つけるのは困難です。

本文書では有償・無償を問わずガントチャートのライブラリは一切利用しておらず、Vue.js を利用してスクラッチからガントチャート の作成を行っています。

内容が比較的多いので 3 回に分けて文書を公開していますが、最終的には下記のようなガントチャートを作成することができます。

完成時のガントチャート

完成時のガントチャート

【実装するガントチャートの主な機能】

  • タスクバーは横方向にドラッグ&ドロップで移動することができ、移動が完了すると日付も一緒に更新されます。
  • タスクバーの両側についている四角いボタンをドラッグ&ドロップすることでタスクの期間を変更することができ、日付も一緒に更新されます。
  • タスクはカテゴリーに分けることができ、カテゴリー毎に表示・非表示を行うことができます。
  • タスクはドラッグ&ドロップで表示順番を変更することができます。カテゴリー間でも移動は可能です。
  • タスクの追加、更新、削除が可能です。
  • 進捗度の設定がタスクバーの背景色に反映されます。

目次

環境

特別な環境を構築することなく手元の環境ですぐに行えるように Vue.js のバージョン 3 の cdn を利用して行っていきます。また CSS には TailwindCss を利用しています。

<script src="https://unpkg.com/vue@next"></script>
<link
  href="https://unpkg.com/tailwindcss@^2/dist/tailwind.min.css"
  rel="stylesheet"
/>
Vue 3 の Composiont API は利用していません。Vue 3 の Teleport 機能をタスク作成、更新のモーダルウィンドウで利用しています。

任意の場所に index.html ファイルを作成し下記のコードを記述します。Vue.js と TailwindCss 以外に時刻と時間を操作するために moment.js ライブラリを利用しています。

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <script src="https://unpkg.com/vue@next"></script>
    <link
      href="https://unpkg.com/tailwindcss@^2/dist/tailwind.min.css"
      rel="stylesheet"
    />
    <script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.29.1/moment.min.js"></script>
    <title>スクラッチから作るガントチャート</title>
  </head>
  <body>
    <div id="app"></div>
  </body>
</html>
<script>
  const app = Vue.createApp({}).mount('#app');
</script>

Tailwind CSS の Class を説明することなくコード中で利用しているため経験のない人には難しく感じるかもしれませんが、Tailwind CSS 自体は全く難しくものではなく今後も役立つ技術なので Tailwind CSS の公式のドキュメントか下記の記事を参考にしてください。

カレンダーの作成

最初にガントチャートのベースとなるカレンダーを作成することから始めます。

完成時のカレンダー

カレンダーの完了後にブラウザで index.html ファイルを閲覧すると左側にタスク領域、右側にカレンダー領域を持つガントチャートが表示されます。

本日の日付の背景色が赤になります。土日も背景色により区別されます。設定した日程の移動は画面の下部にあるスクロールバーによって行うことができます。

カレンダーの作成

カレンダーの作成

領域の作成

領域毎に作成を行っていますが大きく 3 つの領域に分けて作成していきます。

領域の確認

領域の確認

ガントチャート上部にヘッダー、左側の領域をタスク領域、右側の領域をカレンダー領域として 3 つの領域を作成します。

<div id="app">
  <div id="gantt-header" class="h-12 p-2 flex items-center">
    <h1 class="text-xl font-bold">ガントチャート</h1>
  </div>
  <div id="gantt-content" class="flex">
    <div id="gantt-task">タスク領域</div>
    <div id="gantt-calendar">カレンダー領域</div>
  </div>
</div>

ブラウザで確認すると下記のように表示されます。

2つの領域

3 つの領域

タスク領域の作成

タスク領域にはタスク名、開始日、完了期限日などタスク情報が表示される場所を作成します。ここではタスク領域のヘッダーのみ作成しています。

<div id="app">
  <div id="gantt-header" class="h-12 p-2 flex items-center">
    <h1 class="text-xl font-bold">ガントチャート</h1>
  </div>
  <div id="gantt-content" class="flex">
    <div id="gantt-task">
      <div
        id="gantt-task-title"
        class="flex items-center bg-green-600 text-white h-20"
      >
        <div
          class="border-t border-r border-b flex items-center justify-center font-bold text-xs w-48 h-full"
        >
          タスク
        </div>
        <div
          class="border-t border-r border-b flex items-center justify-center font-bold text-xs w-24 h-full"
        >
          開始日
        </div>
        <div
          class="border-t border-r border-b flex items-center justify-center font-bold text-xs w-24 h-full"
        >
          完了期限日
        </div>
        <div
          class="border-t border-r border-b flex items-center justify-center font-bold text-xs w-16 h-full"
        >
          担当
        </div>
        <div
          class="border-t border-r border-b flex items-center justify-center font-bold text-xs w-12 h-full"
        >
          進捗
        </div>
      </div>
    </div>
    <div id="gantt-calendar">カレンダー領域</div>
  </div>
</div>

ブラウザで確認するとタスク領域のヘッダーには後ほど作成するタスク表のタイトルが表示されます。各タイトルの幅は TailwindCss の width クラス w-X(X は 12 or 16 or 24)で設定を行っています。各セルの中心に文字を表示させるためには flex を使っています。

タスク領域の作成

タスク領域の作成

カレンダー領域の作成

block_size と block_number

カレンダーを表示させるためにはガントチャートを表示させたい期間の設定が必要となります。期間の決まったプロジェクトであればプロジェクトの開始から終了までの期間を一度に表示する必要があります。

Vue.js に start_month と end_month という開始月と終了月のデータプロパティを追加します。

カレンダーの一日の横幅を block_size、カレンダーの開始日を 0 として日毎に連番の block_number を付与します。block_number と block_size をかけることで開始日からその日までの幅を求めることができます。

block_size は 30, block_number の初期値は 0 にしています。block_size を変更することで一日の横幅を調整することができます。

Block_sizeとBlock_number

Block_size と Block_number

const app = Vue.createApp({
  data() {
    return {
      start_month: '2020-10',
      end_month: '2021-02',
      block_size: 30,
      block_number: 0,
    };
  },
}).mount('#app');
本設定では開始月を 2020 年 10 月、終了月を 2021 年の 2 月に設定しています。このように設定を行うと 2020 年 10 月 1 日〜2021 年の 2 月 28 日までの期間がカレンダーに表示されます。複数月にまたがってガントチャートを利用することを想定しています。

カレンダー情報の作成

各月の日付と曜日を持った配列を作成するために getDays メソッドを作成します。getDays メソッドは年と月以外に block_number を引数として持ち、日毎に連番の block_number を付与しています。

data() {
  return {
//略
  }
},
methods:{
  getDays(year, month, block_number) {
    const dayOfWeek = ['', '', '', '', '', '', ''];
    let days = [];
    let date = moment(`${year}-${month}-01`);
    let num = date.daysInMonth();
    for (let i = 0; i < num; i++) {
      days.push({
        day: date.date(),
        dayOfWeek: dayOfWeek[date.day()],
        block_number
      })
      date.add(1, 'day');
      block_number++;
    }
    return days;
  },
},
moment.js の day メソッドを利用して曜日番号を取得します。日曜日の場合は 0、月曜日は 1 と曜日番号を取得することができます。その曜日番号と曜日の配列 dayOfWeek を利用して曜日番号から曜日を取得しています。

getDays メソッドを追加後にライフサイクルフック mounted の中で getDasys メソッドを実行すると下記の配列を取得することができます。

data() {
//略
},
methods: {
//略
},
mounted() {
    console.log(this.getDays('2020','10',0))
}
getDaysメソッドの実行結果

getDays メソッドの実行結果

getDays メソッドでは一ヶ月毎の日付と曜日を取得することができるので、4 ヶ月分(2020 年 10 月 1 日〜2021 年 2 月 28 日)のデータを取得するために getCalendar メソッドを追加します。

start_month と end_month と moment.js の diff メソッドを利用して、開始月と終了月の間の月数を取得しています。取得した月数を使って for ループで月数分 getDays を実行しています。getCalendar メソッドの中で取得した一ヶ月毎の getDays の値は calendar プロパティに保存しています。この calendars プロパティを後ほど v-for ディレクティブを利用してカレンダー領域で展開します。またカレンダー全体の block_number も取得しています。block_number がわかれば block_size を使うことで 4 ヶ月分(2020 年 10 月 1 日〜2021 年 2 月 28 日)のカレンダー領域の幅を取得することができます。

getCalendar() {
  let block_number = 0;
  let days;
  let start_month = moment(this.start_month)
  let end_month = moment(this.end_month)
  let between_month = end_month.diff(start_month, 'months')
  for (let i = 0; i <= between_month; i++) {
    days = this.getDays(start_month.year(), start_month.format('MM'), block_number);
    this.calendars.push({
      date: start_month.format('YYYY年MM月'),
      year: start_month.year(),
      month: start_month.month(), //month(), 0,1..11と表示
      start_block_number: block_number,
      calendar: days.length,
      days: days
    })
    start_month.add(1, 'months')
    block_number = days[days.length - 1].block_number
    block_number++;
  }
  return block_number;
},
calendars プロパティにはその他にも date プロパティで年数と月(2020 年 10 月等)、year で年数、month で月番号を取得しています。(moment.js では 1 月は 0 という月番号が割り振られます。1 月だから 1 というわけではありません)。start_block_number でその月の最初の block_number を保存しています。これらの値はすべてカレンダー情報を表示させる時に利用します。

Vue.js のデータプロパティへ calendars プロパティの追加します。

data(){
  return {
    start_month: '2020-10',
    end_month: '2021-02',
    block_size: 30,
    block_number: 0,
    calendars:[]
  }
},

getCalendar メソッドはライフサイクルフック mounted で実行します。index.html ファイルを開くと calendars プロパティの中にカレンダー情報が保存されます。

mounted() {
  this.getCalendar();
}

カレンダーの表示

取得した calendars プロパティの中身を v-for ディレクティブを利用して展開します。id="gantt-day"を持つ div 要素を基準要素(position で relative 設定)として CSS の position プロパティの absolute と left プロパティの値で日と曜日を表示する場所を指定しています。場所を指定する際に block_number と block_size も利用することで日毎に block_size 間隔で日付が表示されます。

<div id="gantt-calendar">
  <div id="gantt-day" class="relative h-12">
    <div v-for="(calendar,index) in calendars" :key="index">
      <div v-for="(day,index) in calendar.days" :key="index">
        <div
          class="border-r h-12 absolute flex items-center justify-center flex-col font-bold text-xs"
          :style="`width:${block_size}px;left:${day.block_number*block_size}px`"
        >
          <span>{{ day.day }}</span>
          <span>{{ day.dayOfWeek }}</span>
        </div>
      </div>
    </div>
  </div>
</div>

ブラウザで確認すると日付と曜日だけのカレンダーがカレンダー領域いっぱいに広がって表示されます。

カレンダーを表示

カレンダーを表示

画面下部に表示されているスクロールを利用することで画面に収まりきらない日付や別の月の日付を表示することができます。

日付と曜日では何年の何月かわからないのでその情報も calendars を v-for でループさせることで表示させます。表示させる場所は日付と曜日の上です。ここでは calendars に保存した date や start_block_number を利用しています。div の閉じタグにも注意して設定を行ってください。

<div id="gantt-calendar">
  <div id="gantt-date" class="h-20">
    <div id="gantt-year-month" class="relative h-8">
      <div v-for="(calendar,index) in calendars" :key="index">
        <div
          class="bg-indigo-700 text-white border-b border-r border-t h-8 absolute font-bold text-sm flex items-center justify-center"
          :style="`width:${calendar.calendar*block_size}px;left:${calendar.start_block_number*block_size}px`"
        >
          {{calendar.date}}
        </div>
      </div>
    </div>
    <div id="gantt-day" class="relative h-12">//略</div>
  </div>
</div>

ブラウザで確認すると 2020 年 10 月といった月の情報が日付の上に表示されます。これで何年の何月何日かわかるようになりました。

年数と月を表示

年数と月を表示

スクロールの設定

下部に表示されているスクロールを使って右側にスクロールすることで 2021 年 2 月のカレンダーを見ることはできますが、タスク領域が消えて見えなくなってしまいます。タスク領域が常時表示されるように CSS の overflow プロパティを利用します。

スクロールするとタスク領域が消える

スクロールするとタスク領域が消える

カレンダー領域(div id="gantt-calendar")に overflow-x の scroll を設定します。

<div id="gantt-calendar" class="overflow-x-scroll w-1/2"></div>

x-scroll をカレンダー領域に設定することで先ほどまで全体に表示されていたスクロールがカレンダー領域の下にのみ表示されます。カレンダー領域に入らない情報はスクロールを動かすことでブラウザに表示させることができます。overfow-x 設定後ではスクロールを動かして再度 2021 年 2 月を確認してもタスク領域が表示されたままの状態となります。

タスク領域が常時表示

タスク領域が常時表示

div 要素(id="gantt-calendar")の class に w-1/2 を設定(ブラウザの表示領域の半分の幅)しているのでカレンダー領域がブラウザ画面の 1/2 領域表示されるように設定されています。この状態でブラウザの幅を広げていくと下記の図の左側の空白の領域が増えていきます。

ブラウザの幅を変えると空白の領域が変わる

ブラウザの幅を変えると空白の領域が変わる

カレンダー領域の幅を自動で調整できるようにブラウザのウィンドウの幅の変更を検知できるようにイベントの設定を行います。

ウィンドウの動的な変更への対応

ウィンドウのサイズは window.innerWith で取得することができます。ウィンドウの高さも利用するので高さは windows.innerHeight で一緒に取得します。

データプロパティ inner_width, inner_height を追加し、getWindowSize メソッドで window.innerWith, windows.innerHeight の値を保存します。

data() {
    return {
      //略
      inner_width: '', //追加
      inner_height: '', //追加
    }
getWindowSize() {
    this.inner_width = window.innerWidth;
    this.inner_height = window.innerHeight;
},

mounted フックで getWindowSize を実行しますが、ウィンドウのサイズはユーザによって動的に変更が行われるのでイベントリスナーを追加し、resize イベントを利用してウィンドウのサイズ変更が発生すると getWindwSize メソッドが再実行されるように設定を行います。

mounted() {
  this.getCalendar();
  this.getWindowSize();
  window.addEventListener('resize', this.getWindowSize);
}

ウィンドウ全体からタスク領域分を削除した部分がカレンダー領域になるためタスク領域の幅を計算しておく必要があります。タスク領域のタイトルを包む id="gantt-task-title"を持つ div 要素に ref="task"を設定します。ref を設定すると Vue.js からこの要素に直接アクセスすることができます。

<div
  id="gantt-task-title"
  class="flex items-center bg-green-600 text-white h-20"
  ref="task"
></div>

ref を使って Vue.js からアクセスした要素の幅は offSetWidth を利用して取得します。getWindowSize メソッドの中で innerWitdh と一緒に取得します。

getWindowSize() {
  this.inner_width = window.innerWidth;
  this.inner_height = window.innerHeight;
  this.task_width = this.$refs.task.offsetWidth;
  this.task_height = this.$refs.task.offsetHeight;
},
data() {
    return {
      //略
      inner_width: '',
      inner_height: '',
      task_width: '',//追加
      task_height: '',
    }
offsetHeight を利用して高さの情報も取得しておきます。

カレンダー領域の幅は computed プロパティの calendarViewWidth 内で inner_width と task_width を利用して取得します。

computed: {
  calendarViewWidth() {
    return this.inner_width - this.task_width;
  },
}

calendarViewWidth を id="gantt-calendar"を持つ div タグに設定します。class の中に設定していた w-1/2 は削除してください。

<div
  id="gantt-calendar"
  class="overflow-x-scroll"
  :style="`width:${calendarViewWidth}px`"
></div>

カレンダーがブラウザの右端まで表示され、ウィンドウサイズを変更しても右端にぴったりとくっついたままで表示されます。先ほどまでのようにカレンダー領域の右側に空白の領域が表示されることはありません。

ウィンドウの動的な変更に対応

ウィンドウの動的な変更に対応

タスクバー領域の確保

カレンダーの下にタスクバーを表示させる領域を確保します。そのためには下記の図のタスクバー領域の高さを計算する必要があります。

高さを取得する領域の図

高さを取得する領域の図

高さを出すためにはウィンドウの高さからヘッダー領域(ガントチャートと表示)、タスクタイトル領域、下部のスクロールバーの高さを引く必要があります。

下記のように computed プロパティの calendarViewHeight で計算します。48 はヘッダー領域の高さ、20 はスクロールバーの高さを引いています。特に 48 については今回は直接数字を入力しているのでヘッダーの高さを変更した場合はこの値を調整する必要があります。タスクタイトルの高さで ref を利用したようにヘッダーの高さも ref を利用して取得することも可能です。

calendarViewHeight() {
  return this.inner_height - this.task_height - 48 - 20;
},

id="gantt-day"を持つ div 要素の下に gantt-height を持つ div 要素を追加しています。また gantt-calendar を持つ div 要素の下に gantt-bar-area を追加しています。gantt-bar-area には後ほどタスクバーを追加します。

<div
  id="gantt-calendar"
  class="overflow-x-scroll"
  :style="`width:${calendarViewWidth}px`"
>
  <div id="gantt-date" class="h-20">
    <div id="gantt-year-month" class="relative h-8">
      <div v-for="(calendar,index) in calendars" :key="index">
        <div
          class="bg-indigo-700 text-white border-b border-r border-t h-8 absolute font-bold text-sm flex items-center justify-center"
          :style="`width:${calendar.calendar*block_size}px;left:${calendar.start_block_number*block_size}px`"
        >
          {{calendar.date}}
        </div>
      </div>
    </div>
    <div id="gantt-day" class="relative h-12">
      <div v-for="(calendar,index) in calendars" :key="index">
        <div v-for="(day,index) in calendar.days" :key="index">
          <div
            class="border-r border-b h-12 absolute flex items-center justify-center flex-col font-bold text-xs"
            :style="`width:${block_size}px;left:${day.block_number*block_size}px`"
          >
            <span>{{ day.day }}</span>
            <span>{{ day.dayOfWeek }}</span>
          </div>
        </div>
      </div>
    </div>
    <div id="gantt-height" class="relative">
      //追加
      <div v-for="(calendar,index) in calendars" :key="index">
        <div v-for="(day,index) in calendar.days" :key="index">
          <div
            class="border-r border-b absolute"
            :style="`width:${block_size}px;left:${day.block_number*block_size}px;height:${calendarViewHeight}px`"
          ></div>
        </div>
      </div>
    </div>
  </div>
  <div
    id="gantt-bar-area"
    class="relative"
    :style="`width:${calendarViewWidth}px;height:${calendarViewHeight}px`"
  >
    //追加
  </div>
</div>

ブラウザで確認するとカレンダーの日付の下にタスクバーの領域が表示されます。ウィンドウの高さを変更しても高さに応じてタスクバーの領域が動的に変わります。縦に表示されている線が途中で切れることはありません。

タスクバー領域

タスクバー領域

週末土日の背景色を設定

週末土日が変わるように class バインディングを利用して指定した色を背景に設定します。

<div id="gantt-day" class="relative h-12">
  <div v-for="(calendar,index) in calendars" :key="index">
    <div v-for="day in calendar.days" :key="index">
      <div class="border-r border-b h-12 absolute flex items-center justify-center flex-col font-bold text-xs"
        :class="{'bg-blue-100': day.dayOfWeek === '土', 'bg-red-100': day.dayOfWeek ==='日'}" //追加
        :style="`width:${block_size}px;left:${day.block_number*block_size}px`">
        <span>{{ day.day }}</span>
        <span>{{ day.dayOfWeek }}</span>
      </div>
    </div>
  </div>
</div>

day.dayOfWeek に設定されている値が土であれば bg-blue-100, 日であれば bg-red-100 を設定しています。

:class="{'bg-blue-100': day.dayOfWeek === '土', 'bg-red-100': day.dayOfWeek
==='日'}"

id="gantt-height"を持つ div の中にも設定を行います。

<div id="gantt-height" class="relative">
  <div v-for="(calendar,index) in calendars" :key="index">
    <div v-for="day in calendar.days" :key="index">
      <div
        class="border-r border-b absolute"
        :class="{'bg-blue-100': day.dayOfWeek === '土', 'bg-red-100': day.dayOfWeek ==='日'}"
        :style="`width:${block_size}px;left:${day.block_number*block_size}px;height:${calendarViewHeight}px`"
      ></div>
    </div>
  </div>
</div>

ブラウザで確認すると土曜日と日曜日の背景にそれぞれ薄いブルーと赤が設定されることが確認できます。

土曜と日曜に背景色を設定

土曜と日曜に背景色を設定

本日を中心に表示

ここまでの設定ではブラウザで index.html ファイルを開くと start_month で設定した 2020-10 の 1 日から表示されます。ガントチャートであれば本日の各タスクの進捗状態を知りたいので本日がカレンダー領域の真ん中に表示されることが望ましいです。

ブラウザで見る時に本日がカレンダー領域の真ん中に表示されるように設定を行います。データプロパティに today を追加し、moment で本日の時刻情報を保存します。

data(){
  return {
    start_month: '2020-10',
    end_month: '2021-02',
    block_size: 30,
    block_number: 0,
    calendars:[],
    inner_width:'',
    inner_height:'',
    task_width:'',
    task_height:'',
    today:moment(),//追加
  }
},

本日の場所を設定するためには start_month の 1 日から本日までに何日あるかを計算します。computed プロパティ scrollDistance を追加します。日数に block_size をかけることでカレンダー領域の左端(ここでは 2020 年 10 月 1 日)からの距離がわかります。

scrollDistance() {
  let start_date = moment(this.start_month);
  let between_days = this.today.diff(start_date, 'days')
  return between_days * this.block_size;
},

その位置までカレンダー領域の位置を移動させるため要素の scrollLeft に移動距離を指定して移動を行います。移動させたい要素は id="gantt-calendar"を持つ div 要素なので Vue.js からアクセスできるように ref="calendar"を設定します。

<div
  id="gantt-calendar"
  class="overflow-x-scroll"
  :style="`width:${calendarViewWidth}px`"
  ref="calendar"
></div>

アクセスした calendar 要素の scollLeft に scrollDistance プロパティの値を設定するために todayPosition メソッドを追加します。

todayPosition() {
  this.$refs.calendar.scrollLeft = this.scrollDistance
},

mounted フックに todayPostioion メソッドを実行することでブラウザで index.html を開いた時に本日の場所まで移動させます。

    mounted() {
      this.getCalendar();
      this.getWindowSize();
      this.$nextTick(() => {
        this.todayPosition();
      })

ブラウザで確認すると本日の場所(11 月 25 日)まで移動してカレンダーを表示してくれますが、カレンダー領域の真ん中ではなくタスク領域のすぐ右に表示されます。

本日への移動

本日への移動

表示されているカレンダー領域の真ん中に表示されるように調整が必要です。computed プロパティの scrollDistance を更新します。表示されている calendarViewWidth の半分の距離をひきます。

scrollDistance() {
  let start_date = moment(this.start_month);
  let between_days = this.today.diff(start_date, 'days')
  return (between_days + 1) * this.block_size - this.calendarViewWidth / 2;
},

調整した結果、11 月 25 日がカレンダー領域の真ん中に表示されるようになります。

本日がカレンダー領域の真ん中に表示

本日がカレンダー領域の真ん中に表示

本日を強調するために class バインディングを使って本日の背景を赤に設定します。

<div id="gantt-day" class="relative h-12">
  <div v-for="(calendar,index) in calendars" :key="index">
    <div v-for="(day,index) in calendar.days" :key="index">
      <div
        class="border-r border-b h-12 absolute flex items-center justify-center flex-col font-bold text-xs"
        :class="{'bg-blue-100': day.dayOfWeek === '土', 'bg-red-100': day.dayOfWeek ==='日',
        'bg-red-600 text-white': calendar.year=== today.year() && calendar.month === today.month() && day.day === today.date()}"
        :style="`width:${block_size}px;left:${day.block_number*block_size}px`"
      >
        <span>{{ day.day }}</span>
        <span>{{ day.dayOfWeek }}</span>
      </div>
    </div>
  </div>
</div>

再度ブラウザで確認すると本日の背景が赤になり、本日がカレンダー領域の真ん中に表示されていることがさらにはっきりとわかります。

本日の背景色を赤に

本日の背景色を赤に

ここまでの設定でガントチャート の土台となるカレンダーを作成することができました。

次はこのカレンダーの上にタスクバーを表示させていきます。

タスクの表示

データプロパティの追加

ここから作成したタスク領域とカレンダー領域にタスク情報を追加していきます。タスクの情報を持った配列 tasks プロパティとタスクのカテゴリー情報を持つ categories プロパティを追加します。

data(){
    return {
    //略
    categories: [
        {
        id: 1,
        name: 'テストA',
        collapsed: false,
        }, {
        id: 2,
        name: 'テストB',
        collapsed: false,
        }
    ],
    tasks: [
        {
        id: 1,
        category_id: 1,
        name: 'テスト1',
        start_date: '2020-11-18',
        end_date: '2020-11-20',
        incharge_user: '鈴木',
        percentage: 100,
        },
        {
        id: 2,
        category_id: 1,
        name: 'テスト2',
        start_date: '2020-11-19',
        end_date: '2020-11-23',
        incharge_user: '佐藤',
        percentage: 90,
        },
        {
        id: 3,
        category_id: 1,
        name: 'テスト3',
        start_date: '2020-11-19',
        end_date: '2020-12-04',
        incharge_user: '鈴木',
        percentage: 40,
        },
        {
        id: 4,
        category_id: 1,
        name: 'テスト4',
        start_date: '2020-11-21',
        end_date: '2020-11-30',
        incharge_user: '山下',
        percentage: 60,
        },
        {
        id: 5,
        category_id: 1,
        name: 'テスト5',
        start_date: '2020-11-25',
        end_date: '2020-12-04',
        incharge_user: '佐藤',
        percentage: 5,
        },
        {
        id: 6,
        category_id: 2,
        name: 'テスト6',
        start_date: '2020-11-28',
        end_date: '2020-12-08',
        incharge_user: '佐藤',
        percentage: 0,
        },
    ],
    }
},

タスクだけではなくカテゴリー情報もタスク領域に表示させるため tasks と categories プロパティを合わせた computud プロパティの lists を追加します。

lists() {
  let lists = [];
  this.categories.map(category => {
    lists.push({ cat: 'category', ...category });
    this.tasks.map(task => {
      if (task.category_id === category.id) {
        lists.push({ cat: 'task', ...task })
      }
    })
  })
  return lists;
}

computed プロパティの lists は下記のような構造をしており、カテゴリー毎にタスクが分類されます。 配列の要素がカテゴリーかタスクかわかるように cat プロパティを追加しています。この値を利用することで lists を展開する際にタスクなのかカテゴリーなのかを識別することができます。

タスクとカテゴリーを合わせたリストの構造

タスク領域へのタスクの表示

v-for ディレクティブを利用してタスク領域に lists を表示します。

<div id="gantt-content" class="flex">
  <div id="gantt-task">
    <div
      id="gantt-task-title"
      class="flex items-center bg-green-600 text-white h-20"
      ref="task"
    >
      //略
    </div>
    <div id="gantt-task-list">
      <div
        v-for="(list,index) in lists"
        :key="index"
        class="flex h-10 border-b"
      >
        <div class="border-r flex items-center font-bold w-48 text-sm pl-4">
          {{list.name}}
        </div>
        <div class="border-r flex items-center justify-center w-24 text-sm">
          {{list.start_date}}
        </div>
        <div class="border-r flex items-center justify-center w-24 text-sm">
          {{list.end_date}}
        </div>
        <div class="border-r flex items-center justify-center w-16 text-sm">
          {{list.incharge_user}}
        </div>
        <div class="flex items-center justify-center w-12 text-sm">
          {{list.percentage}}%
        </div>
      </div>
    </div>
  </div>
  <div
    id="gantt-calendar"
    class="overflow-x-scroll"
    :style="`width:${calendarViewWidth}px`"
    ref="calendar"
  >
    //略
  </div>
</div>

カテゴリーとタスクは表示されましたがカテゴリーの場合は進捗、開始日などの情報もないのでカテゴリーとタスクで表示を変える必要があります。

タスクとカテゴリーを表示

タスクとカテゴリーを表示

v-if ディレクティブと lists 作成時に追加した cat プロパティを利用してカテゴリーとタスクで表示を変更します。

<div id="gantt-task-list">
  <div v-for="(list,index) in lists" :key="index" class="flex h-10 border-b">
    <template v-if="list.cat === 'category'">
      <div class="flex items-center font-bold w-full text-sm pl-2">
        {{list.name}}
      </div>
    </template>
    <template v-else>
      <div class="border-r flex items-center font-bold w-48 text-sm pl-4">
        {{list.name}}
      </div>
      <div class="border-r flex items-center justify-center w-24 text-sm">
        {{list.start_date}}
      </div>
      <div class="border-r flex items-center justify-center w-24 text-sm">
        {{list.end_date}}
      </div>
      <div class="border-r flex items-center justify-center w-16 text-sm">
        {{list.incharge_user}}
      </div>
      <div class="flex items-center justify-center w-12 text-sm">
        {{list.percentage}}%
      </div>
    </template>
  </div>
</div>

再度ブラウザで確認するとカテゴリーとタスクで表示が異なることが確認できます。

カテゴリーとタスクで表示変更

カテゴリーとタスクで表示変更

ここまでの設定でタスク領域にカテゴリーとタスク情報を表示させることができました。

カレンダー領域へのタスクバーの表示

次にタスクとカテゴリー情報を利用してカレンダー領域にタスクバーの表示を行います。

タスクバー一つ一つの位置は id="gantt-bar-area"を持つ div 要素を基準にして表示位置が決まります。CSS の position プロパティを absolute に設定し、top と left で表示する位置を決め、width でタスクバーの長さが決まります。width は task の開始日の start_date と完了締切日の end_date から計算することができます。

task のタスクバーの top, left, width を計算する computed プロパティの taskBars を追加します。

taskBars() {
  let start_date = moment(this.start_month);
  let top = 10;
  let left;
  let between;
  let start;
  let style;
  return this.lists.map(list => {
    style = {}
    if(list.cat==='task'){
      let date_from = moment(list.start_date);
      let date_to = moment(list.end_date);
      between = date_to.diff(date_from, 'days');
      between++;
      start = date_from.diff(start_date, 'days');
      left = start * this.block_size;
      style = {
        top: `${top}px`,
        left: `${left}px`,
        width: `${this.block_size * between}px`,
      }
    }
    top = top + 40;
    return {
      style,
      list
    }
  })
},

top の初期値 10 は各タスクバーに確保された高さの領域(h-10=2.5rem=40px)の上から 10px からタスクバーを表示させるために指定しています。lists をループする毎に top に 40px を足して、各タスクバーが確保した高さの 10px 下からタスクバーを表示するようにしています。

デフォルトでは 1rem は 16px ですが、環境により 1rem の px は変わるので注意が必要です。

start_date と end_date の日付から moment.js の diff メソッドを利用してその間の日数を出し、block_size をかけることでタスクバーの幅を計算しています。

left は今回の start_month である 2020 年 10 月 1 日から各タスクの start_date までの日数を出して、block_size をかけることでカレンダー領域の左端からどの位置にタスクバーの表示を開始させるのかの計算して設定しています。

computed プロパティ taskBars を v-for ディレクティブで展開して style バインディングで計算した top, left, width をタスクバー要素に適用します。

<div
  id="gantt-bar-area"
  class="relative"
  :style="`width:${calendarViewWidth}px;height:${calendarViewHeight}px`"
>
  <div v-for="(bar,index) in taskBars" :key="index">
    <div
      :style="bar.style"
      class="rounded-lg absolute h-5 bg-yellow-100"
      v-if="bar.list.cat === 'task'"
    >
      <div class="w-full h-full"></div>
    </div>
  </div>
</div>

ブラウザで確認すると薄い黄色が設定されたタスクバーがカレンダー領域に表示されます。

カレンダー領域へのタスクバーの表示

カレンダー領域へのタスクバーの表示

Tailwind CSS のバージョンによってデフォルトで利用できる色が異なる場合があります。bg-orange-XXX は Tailwind CSS ではないため当初設定した bg-orange-200 から bg-yellow-100 に変更をしています。

タスク領域とカレンダー領域へのタスクを表示することができました。もしカレンダー領域のタスクバーにドラッグ&ドロップなどのインタラクティブな機能を持たせる必要でないのであればガントチャートの作成は完了です。

インタラクティブな機能を追加しない場合もタスクの作成、追加、削除の機能は必要となります。

表示の設定(overflow)

カレンダー領域を設定する際にウィンドウサイズの変更にも対応できるように設定を行いました。

タスク領域の高さがブラウザのウィンドウサイズの高さよりも小さい場合は問題ありませんが、ブラウザのウィンドウサイズを小さくすると右側に複数のスクロールバーが表示されます。

複数のスクロールバー表示

複数のスクロールバー表示

カレンダー領域にスクロールバーが表示されているので、id="gantt-calendar"を持つ div 要素に overflow-y

を設定してスクロールバーを非表示にします。

<div
  id="gantt-calendar"
  class="overflow-x-scroll overflow-y-hidden border-l"
  :style="`width:${calendarViewWidth}px`"
  ref="calendar"
></div>

設定するとスクロールバーは非表示になりますが、表示されているもう一つのスクロールバーでスクロールするとカレンダー領域にある縦線の一部が途中から表示されていないことが確認できます。

カレンダーの縦線が表示されない

カレンダーの縦線が表示されない

タスク領域にもウィンドウサイズによって高さが動的に変わるように calendarViewHeight を設定します。calendarViewHeight を設定しただけではカレンダーの縦線が切れてしまう問題は解消しないため、一緒に overflow-y-hidden を設定します。

<div
  id="gantt-task-list"
  class="overflow-y-hidden"
  :style="`height:${calendarViewHeight}px`"
></div>

スクロールバーは消えましたが、スクロールバーがないためブラウザのウィンドウズサイズの高さに入らない領域にあるタスク(タスク一覧の下にあるタスク)を表示することができなくなります。

overflow-y:hidden設定後スクロールできなくなる

overflow-y

設定後スクロールできなくなる

ブラウザのウィンドウズサイズの高さが十分でない場合、実際にはテスト 5 の下にテスト B とテスト 6 が存在するがスクロールできないためブラウザ上に表示させることができない。

ここからは overflow ではなく JavaScript の力を借りてスクロール機能を実装して表示されていないタスクをブラウザに表示させます。

JavaScript の Wheel イベント

ブラウザのウィンドウサイズの変化の検知に resize イベントのイベントリスナーを設定しましたが、スクロールの場合は wheel イベントを設定します。

mounted() {
//略
  window.addEventListener('resize', this.getWindowSize);
  window.addEventListener('wheel', this.windowSizeCheck);
}

wheel イベントを設定することでマウスのホイールを動かす度に this.windowSizeCheck メソッドが実行されます。windowSizeCheck メソッドでは position_id というデータプロパティを使います。カレンダーのウィンドウサイズの高さよりもタスクのリストの高さが高い場合はスクールすることで position_id が 1 増えます。position_id はタスク一覧のリストの上からの順番を保存します。

windowSizeCheck() {
  let height = this.lists.length - this.position_id
  if (event.deltaY > 0 && height * 40 > this.calendarViewHeight) {
    this.position_id++
  } else if (event.deltaY < 0 && this.position_id !== 0) {
    this.position_id--
  }
},
Vue.js のデータプロパティに position_id:0 を追加してください。

次に computed プロパティの displayTasks を追加し、ブラウザに表示することができる領域内で表示させるタスク一覧を取得します。

calendarViewHeight の中に何個のタスク情報が表示できるかを計算して、lists の中から表示させたいタスクのみ取り出します。先頭の何番名から取り出すかを position_id を使って指定します。

displayTasks() {
  let display_task_number = Math.floor(this.calendarViewHeight / 40);
  return this.lists.slice(this.position_id, this.position_id + display_task_number);
},

position_id が 2 で calendarViewHeight の中に 5 つのタスクが入る場合はタスク一覧から下記の赤四角にあるタスクがブラウザに表示されます。赤四角はスクーロをすると上下に移動します。ウィンドウサイズがさらに小さくなると calendarViewHeight に入るタスクの数も減るためブラウザに表示されるタスクも減ります。

displayTasksから取得されるタスク

displayTasks から取得されるタスク

これまでタスク領域に表示させるタスクは lists を利用していましたが、displayTasks に変更します。展開後の list も task に変更します。

<div
  id="gantt-task-list"
  class="overflow-y-hidden"
  :style="`height:${calendarViewHeight}px`"
>
  <div
    v-for="(task,index) in displayTasks"
    :key="index"
    class="flex h-10 border-b"
  >
    <template v-if="task.cat === 'category'">
      <div class="flex items-center font-bold w-full text-sm pl-2">
        {{task.name}}
      </div>
    </template>
    <template v-else>
      <div class="border-r flex items-center font-bold w-48 text-sm pl-4">
        {{task.name}}
      </div>
      <div class="border-r flex items-center justify-center w-24 text-sm">
        {{task.start_date}}
      </div>
      <div class="border-r flex items-center justify-center w-24 text-sm">
        {{task.end_date}}
      </div>
      <div class="border-r flex items-center justify-center w-16 text-sm">
        {{task.incharge_user}}
      </div>
      <div class="flex items-center justify-center w-12 text-sm">
        {{task.percentage}}%
      </div>
    </template>
  </div>
</div>

computed プロパティの taskBars で利用していた lists を dispalyTasks に変更します。map 関数で展開した値も list から task に変更しています。

taskBars() {
  let start_date = moment(this.start_month);
  let top = 10;
  let left;
  let between;
  let start;
  let style;
  return this.displayTasks.map(task => {
    style = {}
    if(task.cat==='task'){
      let date_from = moment(task.start_date);
      let date_to = moment(task.end_date);
      between = date_to.diff(date_from, 'days');
      between++;
      start = date_from.diff(start_date, 'days');
      left = start * this.block_size;
      style = {
        top: `${top}px`,
        left: `${left}px`,
        width: `${this.block_size * between}px`,
      }
    }
    top = top + 40;
    return {
      style,
      task
    }
  })
},

HTML 中での taskBars の展開後の bar の task.list.cat も bar.task.cat に変更を行います。

<div v-for="(bar,index) in taskBars" :key="index">
  <div
    :style="bar.style"
    class="rounded-lg absolute h-5 bg-yellow-100"
    v-if="bar.task.cat === 'task'"
  >
    <div class="w-full h-full"></div>
  </div>
</div>

displayTasks を利用することでウィンドウサイズの高さがタスク一覧の高さより小さい場合もスクロールすることですべてのタスクを確認することができます。

ホイールスクロールで隠れら領域を表示

ホイールスクロールで隠れた領域を表示

続きは下記の文書で公開しています。

カテゴリー一覧