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>

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

年数と月を表示
年数と月を表示

スクロールの設定

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

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

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


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

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">

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`">

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

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

タスクバー領域の確保

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

高さを取得する領域の図
高さを取得する領域の図

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

下記のように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">

アクセスした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">
    //略

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

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

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:hiddenを設定してスクロールバーを非表示にします。


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

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

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

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


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

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

overfow-y:hidden設定後スクロールできなくなる
overfow-y:hidden設定後スクロールできなくなる
ブラウザのウィンドウズサイズの高さが十分でない場合、実際にはテスト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を利用することでウィンドウサイズの高さがタスク一覧の高さより小さい場合もスクロールすることですべてのタスクを確認することができます。

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

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