Last active
June 20, 2022 05:19
-
-
Save skysan87/e116273036839af363850e7735d4edd4 to your computer and use it in GitHub Desktop.
[Vue]ガントチャート
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!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> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/dayjs/1.11.3/dayjs.min.js"></script> | |
<!-- tailwindcss v3 --> | |
<script src="https://cdn.tailwindcss.com"></script> | |
<title>ガントチャート</title> | |
</head> | |
<body> | |
<div id="app"> | |
<div id="gantt" class="flex"> | |
<div id="gantt-chart-container" ref="calendar" class="overflow-auto h-screen w-screen select-none"> | |
<div id="header" class="top-0 flex z-20 sticky" :style="`width: ${viewWidth}px;`"> | |
<div class="border-b border-black bg-green-100 flex sticky left-0 top-0" | |
:style="`min-width: ${taskWidth}px;`"> | |
<div class="py-2 h-full px-2 border-r border-black text-sm" style="width: 120px">タスク</div> | |
<div class="py-2 h-full text-center border-r border-black text-sm" style="width: 90px">開始日</div> | |
<div class="py-2 h-full text-center border-r border-black text-sm" style="width: 90px">期限日</div> | |
</div> | |
<template v-for="month of calendars" :key="month.title"> | |
<div class="border-b border-black bg-white" :style="`min-width: ${blockWidth * month.days}px;`"> | |
<!-- 年月 --> | |
<div class="border-b border-r border-black px-2">{{ month.title }}</div> | |
<!-- 日付 --> | |
<div class="flex"> | |
<div v-for="day of month.days" :key="day.date" class="border-r border-black text-xs text-center" | |
:class="weekendColor(day.dayOfWeek)" :style="`min-width: ${blockWidth}px;`"> | |
{{ day.date }} | |
</div> | |
</div> | |
<!-- 曜日 --> | |
<div class="flex"> | |
<div v-for="day of month.days" :key="day.date" class="border-r border-black text-xs text-center" | |
:class="weekendColor(day.dayOfWeek)" :style="`min-width: ${blockWidth}px;`"> | |
{{ day.dayOfWeek }} | |
</div> | |
</div> | |
</div> | |
</template> | |
</div> | |
<div id="contents" class="relative" :style="`width: ${viewWidth}px;`"> | |
<!-- timeline --> | |
<div v-for="i of totalDays" :key="i" class="absolute bg-gray-200 h-full w-px" | |
:style="`left: ${i * blockWidth + taskWidth - 1}px;`"> | |
</div> | |
<!-- today --> | |
<div v-if="todayPosition >= 0" class="absolute bg-red-300 h-full" | |
:style="`width: ${blockWidth - 1}px; left: ${todayPosition}px;`"> | |
</div> | |
<!-- contents --> | |
<div v-for="task of taskRows" :key="task.id" class="h-10 border-b border-black flex"> | |
<div class="bg-green-100 z-10 flex sticky left-0" :style="`min-width: ${taskWidth}px;`"> | |
<div class="py-2 h-full px-2 border-r border-black text-sm" style="width: 120px">{{ task.name }}</div> | |
<div class="py-2 h-full text-center border-r border-black text-sm" style="width: 90px">{{ task.startDate}}</div> | |
<div class="py-2 h-full text-center border-r border-black text-sm" style="width: 90px">{{ task.endDate }}</div> | |
</div> | |
<div :style="task.style" class="h-10 flex py-2 will-change-transform" | |
@mousedown="onMouseDown_MoveStart($event, task)"> | |
<div class="w-2 bg-yellow-200 rounded-l-lg cursor-col-resize" | |
@mousedown.stop="onMouseDown_ResizeStart($event, task, 'left')"> | |
</div> | |
<div class="flex-1 bg-yellow-200 pointer-events-none"></div> | |
<div class="w-2 bg-yellow-200 rounded-r-lg cursor-col-resize" | |
@mousedown.stop="onMouseDown_ResizeStart($event, task, 'right')"> | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
</body> | |
<script> | |
const BLOCK_SIZE = 20 | |
const TASK_WIDTH = 300 | |
Vue.createApp({ | |
data () { | |
const _today = dayjs() | |
return { | |
dragging: false, | |
leftResizing: false, | |
rightResizing: false, | |
target: { | |
pageX: 0, | |
element: null, | |
task: null | |
}, | |
blockWidth: BLOCK_SIZE, | |
taskWidth: TASK_WIDTH, | |
viewWidth: 0, | |
contentWidth: 0, | |
totalDays: 0, | |
startMonth: _today.add(0, 'month').format('YYYY-MM'), | |
endMonth: _today.add(1, 'month').format('YYYY-MM'), | |
calendars: [], | |
tasks: [] | |
} | |
}, | |
mounted () { | |
this.initView() | |
this.makeTestData() | |
document.addEventListener('mousemove', this.onMouseDown_Moving) | |
document.addEventListener('mouseup', this.onMouseDown_MoveStop) | |
document.addEventListener('mousemove', this.onMouseDown_Resizing) | |
document.addEventListener('mouseup', this.onMouseDown_ResizeStop) | |
}, | |
unmounted () { | |
document.removeEventListener('mousemove', this.onMouseDown_Moving) | |
document.removeEventListener('mouseup', this.onMouseDown_MoveStop) | |
document.removeEventListener('mousemove', this.onMouseDown_Resizing) | |
document.removeEventListener('mouseup', this.onMouseDown_ResizeStop) | |
}, | |
computed: { | |
taskRows () { | |
const startMonth = dayjs(this.startMonth) | |
const endMonth = dayjs(this.endMonth) | |
return this.tasks.map((task) => { | |
const dateFrom = dayjs(task.startDate) | |
const dateTo = dayjs(task.endDate) | |
const between = dateTo.diff(dateFrom, 'day') + 1 | |
const start = dateFrom.diff(startMonth, 'day') | |
const end = endMonth.diff(dateTo, 'day') + endMonth.daysInMonth() | |
const pos = { | |
x: start * BLOCK_SIZE, | |
width: BLOCK_SIZE * between | |
} | |
const style = { | |
width: `${pos.width}px`, | |
transform: `translateX(${pos.x}px)` | |
} | |
// 表示範囲外の日付を含む場合は表示しない | |
const isHidden = !(start >= 0 && end > 0) | |
if (isHidden) { | |
style.display = 'none' | |
} | |
return { | |
style, | |
pos, | |
isHidden, | |
...task, | |
} | |
}) | |
}, | |
todayPosition () { | |
const today = dayjs() | |
const startDate = dayjs(this.startMonth) | |
const endDate = dayjs(this.endMonth) | |
const diffFuture = today.diff(startDate, 'day') | |
const diffPast = endDate.diff(today, 'day') + endDate.daysInMonth() | |
return (diffFuture >= 0 && diffPast > 0) | |
? diffFuture * BLOCK_SIZE + TASK_WIDTH | |
: -1 | |
} | |
}, | |
methods: { | |
initView () { | |
this.serCalendar() | |
this.totalDays = this.calendars.reduce((p, c) => p + c.days.length, 0) | |
this.contentWidth = this.totalDays * this.blockWidth | |
this.viewWidth = this.taskWidth + this.contentWidth | |
this.$nextTick(() => { | |
this.$refs.calendar.scrollLeft = this.todayPosition - TASK_WIDTH | |
}) | |
}, | |
getDays (startMonth) { | |
const dayOfWeek = ['日', '月', '火', '水', '木', '金', '土'] | |
const days = [] | |
for (let i = 0; i < startMonth.daysInMonth(); i++) { | |
const targetDate = startMonth.add(i, 'day') | |
days.push({ | |
date: targetDate.date(), | |
dayOfWeek: dayOfWeek[targetDate.day()] | |
}) | |
} | |
return days | |
}, | |
serCalendar () { | |
const startMonth = dayjs(this.startMonth) | |
const endMonth = dayjs(this.endMonth) | |
const betweenMonth = endMonth.diff(startMonth, 'month') | |
for (let i = 0; i <= betweenMonth; i++) { | |
const targetMonth = startMonth.add(i, 'month') | |
this.calendars.push({ | |
title: targetMonth.format('YYYY-MM'), | |
days: this.getDays(targetMonth) | |
}) | |
} | |
}, | |
onMouseDown_MoveStart (e, task) { | |
this.dragging = true | |
this.target.pageX = e.pageX | |
this.target.element = e.target | |
this.target.task = task | |
}, | |
onMouseDown_Moving (e) { | |
if (!this.dragging) return | |
const realX = this.calcMovePositionX(e.pageX) | |
this.target.element.style.transform = `translateX(${realX}px)` | |
}, | |
onMouseDown_MoveStop (e) { | |
if (!this.dragging) return | |
const realX = this.calcMovePositionX(e.pageX) | |
// 日付線にフィットさせる | |
const days = Math.round((this.target.task.pos.x - realX) / BLOCK_SIZE) | |
if (days !== 0) { | |
const task = this.tasks.find(task => task.id === this.target.task.id) | |
task['startDate'] = dayjs(task.startDate).add(-days, 'day').format('YYYY-MM-DD') | |
task['endDate'] = dayjs(task.endDate).add(-days, 'day').format('YYYY-MM-DD') | |
} else { | |
this.target.element.style.transform = `translateX(${this.target.task.pos.x}px)` | |
} | |
this.dragging = false | |
this.target.element = null | |
this.target.task = null | |
this.target.pageX = 0 | |
}, | |
onMouseDown_ResizeStart (e, task, direction) { | |
if (direction === 'left') { | |
this.leftResizing = true | |
} else { | |
this.rightResizing = true | |
} | |
this.target.pageX = e.pageX | |
this.target.element = e.target.parentElement | |
this.target.task = task | |
}, | |
onMouseDown_Resizing (e) { | |
if (this.leftResizing) { | |
const realX = this.calcResizePositionX(e.pageX) | |
const realWidth = this.calcLeftResizeWidth(e.pageX) | |
this.target.element.style.transform = `translateX(${realX}px)` | |
this.target.element.style.width = `${realWidth}px` | |
} | |
if (this.rightResizing) { | |
const realWidth = this.calcRightResizeWidth(e.pageX) | |
this.target.element.style.width = `${realWidth}px` | |
} | |
}, | |
onMouseDown_ResizeStop (e) { | |
if (this.leftResizing) { | |
const realX = this.calcResizePositionX(e.pageX) | |
// 日付線にフィットさせる | |
const days = Math.round((this.target.task.pos.x - realX) / BLOCK_SIZE) | |
if (days !== 0) { | |
const task = this.tasks.find(task => task.id === this.target.task.id) | |
task['startDate'] = dayjs(task.startDate).add(-days, 'day').format('YYYY-MM-DD') | |
} else { | |
this.target.element.style.transform = `translateX(${this.target.task.pos.x}px)` | |
this.target.element.style.width = `${this.target.task.pos.width}px` | |
} | |
} | |
if (this.rightResizing) { | |
const realWidth = this.calcRightResizeWidth(e.pageX) | |
// 日付線にフィットさせる | |
const days = Math.round((this.target.task.pos.width - realWidth) / BLOCK_SIZE) | |
if (days !== 0) { | |
const task = this.tasks.find(task => task.id === this.target.task.id) | |
task['endDate'] = dayjs(task.endDate).add(-days, 'day').format('YYYY-MM-DD') | |
} else { | |
this.target.element.style.width = `${this.target.task.pos.width}px` | |
} | |
} | |
this.leftResizing = false | |
this.rightResizing = false | |
this.target.element = null | |
this.target.task = null | |
this.target.pageX = 0 | |
}, | |
calcMovePositionX (currentPageX) { | |
const diff = this.target.pageX - currentPageX | |
return this.keepThreshold( | |
this.target.task.pos.x - diff | |
, 0 | |
, this.contentWidth - this.target.task.pos.width | |
) | |
}, | |
calcResizePositionX (currentPageX) { | |
const diff = this.target.pageX - currentPageX | |
return this.keepThreshold( | |
this.target.task.pos.x - diff | |
, 0 | |
, this.target.task.pos.x + this.target.task.pos.width - BLOCK_SIZE | |
) | |
}, | |
calcLeftResizeWidth (currentPageX) { | |
const diff = this.target.pageX - currentPageX | |
return this.keepThreshold( | |
this.target.task.pos.width + diff | |
, BLOCK_SIZE | |
, this.target.task.pos.width + this.target.task.pos.x | |
) | |
}, | |
calcRightResizeWidth (currentPageX) { | |
const diff = this.target.pageX - currentPageX | |
return this.keepThreshold( | |
this.target.task.pos.width - diff | |
, BLOCK_SIZE | |
, this.contentWidth - this.target.task.pos.x | |
) | |
}, | |
keepThreshold (value, min, max) { | |
if (value <= min) return min | |
if (value >= max) return max | |
return value | |
}, | |
weekendColor (dayOfWeek) { | |
switch (dayOfWeek) { | |
case '土': | |
return 'bg-blue-100' | |
case '日': | |
return 'bg-red-100' | |
default: | |
return '' | |
} | |
}, | |
makeTestData () { | |
const today = dayjs() | |
for (let i = 1; i <= 100; i++) { | |
this.tasks.push({ | |
id: i, | |
name: `task - ${i}`, | |
startDate: today.format('YYYY-MM-DD'), | |
endDate: today.add(Math.floor(Math.random() * 5), 'day').format('YYYY-MM-DD') | |
}) | |
} | |
} | |
} | |
}).mount('#app') | |
</script> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
DEMO
demo.mov