This commit is contained in:
unknown committed 2020-07-22 20:50:43 +03:00
commit 063ced7795
40 files changed
+25274

No files matched your search

+32
View File
@@ -0,0 +1,32 @@
import React from 'react';
import '../../style.css';
type BarDateHandleProps = {
x: number;
y: number;
width: number;
height: number;
barCornerRadius: number;
onMouseDown: (event: React.MouseEvent<SVGRectElement, MouseEvent>) => void;
};
export const BarDateHandle: React.FC<BarDateHandleProps> = ({
x,
y,
width,
height,
barCornerRadius,
onMouseDown,
}) => {
return (
<rect
x={x}
y={y}
width={width}
height={height}
className="GanttBar-handle"
ry={barCornerRadius}
rx={barCornerRadius}
onMouseDown={onMouseDown}
></rect>
);
};
+98
View File
@@ -0,0 +1,98 @@
import React, { useRef, useState, useEffect } from 'react';
import '../../style.css';
type BarDisplayProps = {
x: number;
y: number;
width: number;
height: number;
isSelected: boolean;
progressWidth: number;
barCornerRadius: number;
text: string;
hasChild: boolean;
arrowIndent: number;
styles?: {
backgroundColor?: string;
backgroundSelectedColor?: string;
progressColor?: string;
progressSelectedColor?: string;
};
onMouseDown: (event: React.MouseEvent<SVGPolygonElement, MouseEvent>) => void;
};
export const BarDisplay: React.FC<BarDisplayProps> = ({
x,
y,
width,
height,
isSelected,
progressWidth,
barCornerRadius,
text,
hasChild,
arrowIndent,
styles,
onMouseDown,
}) => {
const textRef = useRef<SVGTextElement>(null);
const [isTextInside, setIsTextInside] = useState(true);
useEffect(() => {
if (textRef.current)
setIsTextInside(textRef.current.getBBox().width < width);
}, [textRef, width]);
const getProcessColor = () => {
if (isSelected) {
return styles?.progressSelectedColor || '#8282f5';
} else {
return styles?.progressColor || '#a3a3ff';
}
};
const getBarColor = () => {
if (isSelected) {
return styles?.backgroundSelectedColor || '#aeb8c2';
} else {
return styles?.backgroundColor || '#b8c2cc';
}
};
return (
<g onMouseDown={onMouseDown}>
<rect
x={x}
width={width}
y={y}
height={height}
ry={barCornerRadius}
rx={barCornerRadius}
fill={getBarColor()}
className="GanttBar"
/>
<rect
x={x}
width={progressWidth}
y={y}
height={height}
ry={barCornerRadius}
rx={barCornerRadius}
fill={getProcessColor()}
/>
<text
x={
isTextInside
? x + width * 0.5
: x + width + arrowIndent * +hasChild + arrowIndent * 0.2
}
y={y + height * 0.5}
className={`GanttBar-label ${
isTextInside ? '' : 'GanttBar-label-outside'
}`}
ref={textRef}
>
{text}
</text>
</g>
);
};
@@ -0,0 +1,19 @@
import React from 'react';
import '../../style.css';
type BarProgressHandleProps = {
progressPoint: string;
onMouseDown: (event: React.MouseEvent<SVGPolygonElement, MouseEvent>) => void;
};
export const BarProgressHandle: React.FC<BarProgressHandleProps> = ({
progressPoint,
onMouseDown,
}) => {
return (
<polygon
className="GanttBar-handle"
points={progressPoint}
onMouseDown={onMouseDown}
></polygon>
);
};
+126
View File
@@ -0,0 +1,126 @@
import React, { useState } from 'react';
import { Task } from '../../types/public-types';
import { BarProgressHandle } from './bar-progress-handle';
import { BarDateHandle } from './bar-date-handle';
import { BarDisplay } from './bar-display';
import { BarTask } from '../../types/bar-task';
import { BarEvent } from '../Gantt/gantt-content';
import {
progressWithByParams,
getProgressPoint,
} from '../../helpers/bar-helper';
import '../../style.css';
export type BarProps = {
task: BarTask;
arrowIndent: number;
onDoubleClick?: (task: Task) => any;
isProgressChangeable: boolean;
isDateChangeable: boolean;
handleMouseEvents: (
event:
| React.MouseEvent<SVGPolygonElement, MouseEvent>
| React.MouseEvent<SVGGElement, MouseEvent>
| React.MouseEvent<SVGRectElement, MouseEvent>,
eventType: BarEvent,
task: BarTask
) => void;
handleButtonSVGEvents: (
event: React.KeyboardEvent<SVGGElement>,
task: BarTask
) => void;
};
export const Bar: React.FC<BarProps> = ({
task,
arrowIndent,
onDoubleClick,
isProgressChangeable,
isDateChangeable,
handleMouseEvents,
handleButtonSVGEvents,
}) => {
const [isSelected, setIsSelected] = useState(false);
const progressWidth = progressWithByParams(task.x1, task.x2, task.progress);
const progressPoint = getProgressPoint(
progressWidth + task.x1,
task.y,
task.height
);
return (
<g
className="GanttBar-wrapper"
onDoubleClick={() => {
!!onDoubleClick && onDoubleClick(task);
}}
tabIndex={0}
onKeyDown={e => {
handleButtonSVGEvents(e, task);
}}
onMouseEnter={e => {
handleMouseEvents(e, 'mouseenter', task);
}}
onMouseLeave={e => {
handleMouseEvents(e, 'mouseleave', task);
}}
onFocus={() => setIsSelected(true)}
onBlur={() => setIsSelected(false)}
>
<BarDisplay
x={task.x1}
y={task.y}
width={task.x2 - task.x1}
height={task.height}
progressWidth={progressWidth}
barCornerRadius={task.barCornerRadius}
text={task.name}
hasChild={task.barChildren.length > 0}
arrowIndent={arrowIndent}
styles={task.styles}
isSelected={isSelected}
onMouseDown={e => {
isDateChangeable && handleMouseEvents(e, 'move', task);
}}
/>
<g className="handleGroup">
{isDateChangeable && (
<>
{/*left*/}
<BarDateHandle
x={task.x1 + 1}
y={task.y + 1}
width={task.handleWidth}
height={task.height - 2}
barCornerRadius={task.barCornerRadius}
onMouseDown={e => {
handleMouseEvents(e, 'start', task);
}}
/>
{/*right*/}
<BarDateHandle
x={task.x2 - task.handleWidth - 1}
y={task.y + 1}
width={task.handleWidth}
height={task.height - 2}
barCornerRadius={task.barCornerRadius}
onMouseDown={e => {
handleMouseEvents(e, 'end', task);
}}
/>
</>
)}
{isProgressChangeable && (
<BarProgressHandle
progressPoint={progressPoint}
onMouseDown={e => {
console.log('progress');
handleMouseEvents(e, 'progress', task);
}}
/>
)}
</g>
</g>
);
};
+215
View File
@@ -0,0 +1,215 @@
import React, { ReactChild } from 'react';
import { ViewMode } from '../../types/public-types';
import { TopPartOfCalendar } from './top-part-of-calendar';
import {
getLocaleMonth,
getWeekNumberISO8601,
} from '../../helpers/date-helper';
import '../../style.css';
export type CalendarProps = {
dates: Date[];
locale: string;
viewMode: ViewMode;
headerHeight: number;
columnWidth: number;
fontFamily: string;
fontSize: string;
};
export const Calendar: React.FC<CalendarProps> = ({
dates,
locale,
viewMode,
headerHeight,
columnWidth,
fontFamily,
fontSize,
}) => {
const getCalendarValuesForMonth = () => {
let topValues: ReactChild[] = [];
let bottomValues: ReactChild[] = [];
const topDefaultWidth = columnWidth * 6;
const topDefaultHeight = headerHeight * 0.5;
for (let i = 0; i < dates.length; i++) {
const date = dates[i];
let bottomValue = getLocaleMonth(date, locale);
bottomValues.push(
<text
key={bottomValue + date.getFullYear()}
y={headerHeight * 0.8}
x={columnWidth * i + columnWidth * 0.5}
className="GanttCalendar-bottomText"
>
{bottomValue}
</text>
);
if (i === 0 || date.getFullYear() !== dates[i - 1].getFullYear()) {
let topValue = date.getFullYear().toString();
topValues.push(
<TopPartOfCalendar
key={topValue}
value={topValue}
x1Line={columnWidth * i}
y1Line={0}
y2Line={topDefaultHeight}
xText={
topDefaultWidth + columnWidth * i - date.getMonth() * columnWidth
}
yText={topDefaultHeight * 0.9}
/>
);
}
}
return [topValues, bottomValues];
};
const getCalendarValuesForWeek = () => {
let topValues: ReactChild[] = [];
let bottomValues: ReactChild[] = [];
let weeksCount: number = 0;
const topDefaultHeight = headerHeight * 0.5;
for (let i = dates.length - 1; i >= 0; i--) {
const date = dates[i];
let topValue = '';
if (i === 0 || date.getMonth() !== dates[i - 1].getMonth()) {
//top
topValue = `${getLocaleMonth(date, locale)}, ${date.getFullYear()}`;
}
//bottom
const bottomValue = `W${getWeekNumberISO8601(date)}`;
bottomValues.push(
<text
key={date.getTime()}
y={headerHeight * 0.8}
x={columnWidth * i}
className="GanttCalendar-bottomText"
>
{bottomValue}
</text>
);
if (topValue) {
//if last day is new month
if (i !== dates.length - 1) {
topValues.push(
<TopPartOfCalendar
key={topValue}
value={topValue}
x1Line={columnWidth * i + weeksCount * columnWidth}
y1Line={0}
y2Line={topDefaultHeight}
xText={columnWidth * i + columnWidth * weeksCount * 0.5}
yText={topDefaultHeight * 0.9}
/>
);
}
weeksCount = 0;
}
weeksCount++;
}
return [topValues, bottomValues];
};
const getCalendarValuesForDay = () => {
let topValues: ReactChild[] = [];
let bottomValues: ReactChild[] = [];
const topDefaultHeight = headerHeight * 0.5;
for (let i = 0; i < dates.length; i++) {
const date = dates[i];
const bottomValue = date.getDate().toString();
bottomValues.push(
<text
key={date.getTime()}
y={headerHeight * 0.8}
x={columnWidth * i + columnWidth * 0.5}
className="GanttCalendar-bottomText"
>
{bottomValue}
</text>
);
if (
i + 1 !== dates.length &&
date.getMonth() !== dates[i + 1].getMonth()
) {
let topValue = getLocaleMonth(date, locale);
topValues.push(
<TopPartOfCalendar
key={topValue}
value={topValue}
x1Line={columnWidth * (i + 1)}
y1Line={0}
y2Line={topDefaultHeight}
xText={columnWidth * (i + 1) - date.getDate() * columnWidth * 0.5}
yText={topDefaultHeight * 0.9}
/>
);
}
}
return [topValues, bottomValues];
};
const getCalendarValuesForOther = () => {
let topValues: ReactChild[] = [];
let bottomValues: ReactChild[] = [];
let ticks = viewMode === ViewMode.HalfDay ? 2 : 4;
const topDefaultHeight = headerHeight * 0.5;
for (let i = 0; i < dates.length; i++) {
const date = dates[i];
const bottomValue = Intl.DateTimeFormat(locale, {
hour: 'numeric',
}).format(date);
bottomValues.push(
<text
key={date.getTime()}
y={headerHeight * 0.8}
x={columnWidth * i}
className="GanttCalendar-bottomText"
fontFamily={fontFamily}
>
{bottomValue}
</text>
);
if (i === 0 || date.getDate() !== dates[i - 1].getDate()) {
const topValue = `${date.getDate()} ${getLocaleMonth(date, locale)}`;
topValues.push(
<TopPartOfCalendar
key={topValue}
value={topValue}
x1Line={columnWidth * i + ticks * columnWidth}
y1Line={0}
y2Line={topDefaultHeight}
xText={columnWidth * i + ticks * columnWidth * 0.5}
yText={topDefaultHeight * 0.9}
/>
);
}
}
return [topValues, bottomValues];
};
let topValues: ReactChild[] = [];
let bottomValues: ReactChild[] = [];
switch (viewMode) {
case ViewMode.Month:
[topValues, bottomValues] = getCalendarValuesForMonth();
break;
case ViewMode.Week:
[topValues, bottomValues] = getCalendarValuesForWeek();
break;
case ViewMode.Day:
[topValues, bottomValues] = getCalendarValuesForDay();
break;
default:
[topValues, bottomValues] = getCalendarValuesForOther();
break;
}
return (
<g className="calendar" fontSize={fontSize} fontFamily={fontFamily}>
{bottomValues} {topValues}
</g>
);
};
@@ -0,0 +1,41 @@
import React from 'react';
import '../../style.css';
type TopPartOfCalendarProps = {
value: string;
x1Line: number;
y1Line: number;
y2Line: number;
xText: number;
yText: number;
};
export const TopPartOfCalendar: React.FC<TopPartOfCalendarProps> = ({
value,
x1Line,
y1Line,
y2Line,
xText,
yText,
}) => {
return (
<>
<line
x1={x1Line}
y1={y1Line}
x2={x1Line}
y2={y2Line}
className="GanttCalendar-topTick"
key={value + 'line'}
></line>
<text
key={value + 'text'}
y={yText}
x={xText}
className="GanttCalendar-topText"
>
{value}
</text>
</>
);
};
+403
View File
@@ -0,0 +1,403 @@
import React, { useState, useEffect } from 'react';
import { Task, EventOption } from '../../types/public-types';
import { Bar } from '../Bar/bar';
import { BarTask } from '../../types/bar-task';
import { Arrow } from '../Other/arrow';
import {
convertToBarTasks,
progressByX,
startByX,
endByX,
moveByX,
dateByX,
} from '../../helpers/bar-helper';
import { Tooltip } from '../Other/tooltip';
export interface GanttTask extends Task {
x1: number;
x2: number;
y: number;
width: number;
height: number;
}
export type GanttContentProps = {
tasks: Task[];
dates: Date[];
rowHeight: number;
barCornerRadius: number;
columnWidth: number;
barFill: number;
headerHeight: number;
handleWidth: number;
svg: React.MutableRefObject<SVGSVGElement | null>;
timeStep: number;
arrowColor: string;
arrowIndent: number;
fontSize: string;
fontFamily: string;
getTooltipContent?: (
task: Task,
fontSize: string,
fontFamily: string
) => JSX.Element;
} & EventOption;
export type BarEvent =
| 'progress'
| 'end'
| 'start'
| 'move'
| 'mouseenter'
| 'mouseleave'
| '';
export const GanttContent: React.FC<GanttContentProps> = ({
tasks,
rowHeight,
barCornerRadius,
columnWidth,
dates,
barFill,
headerHeight,
handleWidth,
arrowColor,
svg,
timeStep,
fontFamily,
fontSize,
arrowIndent,
onDateChange,
onProgressChange,
onDoubleClick,
onTaskDelete,
getTooltipContent,
}) => {
const [barEvent, setBarEvent] = useState<BarEvent>('');
const [selectedTask, setSelectedTask] = useState<BarTask | null>(null);
const [barTasks, setBarTasks] = useState<BarTask[]>([]);
const [xStep, setXStep] = useState(0);
const [initEventX1Delta, setInitEventX1Delta] = useState(0);
const [isSVGListen, setIsSVGListen] = useState(false);
useEffect(() => {
const dateDelta =
dates[1].getTime() -
dates[0].getTime() -
dates[1].getTimezoneOffset() * 60 * 1000 +
dates[0].getTimezoneOffset() * 60 * 1000;
const newXStep = (timeStep * columnWidth) / dateDelta;
if (newXStep !== xStep) {
setXStep(newXStep);
}
}, [tasks, rowHeight, barCornerRadius, columnWidth, dates, timeStep, xStep]);
useEffect(() => {
const dateDelta =
dates[1].getTime() -
dates[0].getTime() -
dates[1].getTimezoneOffset() * 60 * 1000 +
dates[0].getTimezoneOffset() * 60 * 1000;
const taskHeight = (rowHeight * barFill) / 100;
setBarTasks(
convertToBarTasks(
tasks,
dates,
dateDelta,
columnWidth,
rowHeight,
taskHeight,
headerHeight,
barCornerRadius,
handleWidth
)
);
}, [
tasks,
rowHeight,
barCornerRadius,
columnWidth,
dates,
timeStep,
barFill,
handleWidth,
headerHeight,
]);
useEffect(() => {
/**
* Method handles event in real time(mousemove) and on finish(mouseup)
*/
const handleMouseSVGChangeEventsSubscribe = async (event: MouseEvent) => {
if (!selectedTask || !barEvent) return;
const changedTask = { ...selectedTask } as BarTask;
switch (event.type) {
//On Event changing
case 'mousemove': {
switch (barEvent) {
case 'progress':
changedTask.progress = progressByX(event.offsetX, selectedTask);
break;
case 'start':
let newX1 = startByX(event.offsetX, xStep, selectedTask);
changedTask.x1 = newX1;
changedTask.start = dateByX(
newX1,
selectedTask.x1,
selectedTask.start,
xStep,
timeStep
);
break;
case 'end':
let newX2 = endByX(event.offsetX, xStep, selectedTask);
changedTask.x2 = newX2;
changedTask.end = dateByX(
newX2,
selectedTask.x2,
selectedTask.end,
xStep,
timeStep
);
break;
case 'move':
const [newMoveX1, newMoveX2] = moveByX(
event.offsetX - initEventX1Delta,
xStep,
selectedTask
);
changedTask.start = dateByX(
newMoveX1,
selectedTask.x1,
selectedTask.start,
xStep,
timeStep
);
changedTask.end = dateByX(
newMoveX2,
selectedTask.x2,
selectedTask.end,
xStep,
timeStep
);
changedTask.x1 = newMoveX1;
changedTask.x2 = newMoveX2;
break;
}
//Update internal state
setBarTasks(
barTasks.map(t => (t.id === changedTask.id ? changedTask : t))
);
setSelectedTask(changedTask);
break;
}
//On finish Event
case 'mouseup': {
let eventForExecution: (
task: Task
) => void | Promise<void> = () => {};
switch (barEvent) {
case 'progress':
changedTask.progress = progressByX(event.offsetX, selectedTask);
if (onProgressChange) {
eventForExecution = onProgressChange;
}
break;
case 'start':
const newX1 = startByX(event.offsetX, xStep, selectedTask);
changedTask.start = dateByX(
newX1,
selectedTask.x1,
selectedTask.start,
xStep,
timeStep
);
if (onDateChange && newX1 !== selectedTask.x1) {
eventForExecution = onDateChange;
}
break;
case 'end':
const newX2 = endByX(event.offsetX, xStep, selectedTask);
changedTask.end = dateByX(
newX2,
selectedTask.x2,
selectedTask.end,
xStep,
timeStep
);
if (onDateChange && newX2 !== selectedTask.x2) {
eventForExecution = onDateChange;
}
break;
case 'move':
const [newMoveX1, newMoveX2] = moveByX(
event.offsetX - initEventX1Delta,
xStep,
selectedTask
);
changedTask.start = dateByX(
newMoveX1,
selectedTask.x1,
selectedTask.start,
xStep,
timeStep
);
changedTask.end = dateByX(
newMoveX2,
selectedTask.x2,
selectedTask.end,
xStep,
timeStep
);
if (
onDateChange &&
newMoveX1 !== selectedTask.x1 &&
newMoveX2 !== selectedTask.x2
) {
eventForExecution = onDateChange;
}
break;
}
setBarEvent('');
setSelectedTask(null);
setIsSVGListen(false);
svg.current?.removeEventListener(
'mousemove',
handleMouseSVGChangeEventsSubscribe
);
svg.current?.removeEventListener(
'mouseup',
handleMouseSVGChangeEventsSubscribe
);
//If update successful - update Gantt state, otherwise we shell back old Bar state
await eventForExecution(changedTask);
break;
}
}
};
if (selectedTask && barEvent && !isSVGListen) {
svg.current?.addEventListener(
'mousemove',
handleMouseSVGChangeEventsSubscribe
);
svg.current?.addEventListener(
'mouseup',
handleMouseSVGChangeEventsSubscribe
);
setIsSVGListen(true);
}
}, [
barEvent,
selectedTask,
xStep,
svg,
initEventX1Delta,
barTasks,
onProgressChange,
timeStep,
onDateChange,
isSVGListen,
]);
/**
* Method is Start point of task change
* @param event init mouse event
* @param eventType
* @param task events task
*/
const handleMouseEvents = (
event:
| React.MouseEvent<SVGPolygonElement, MouseEvent>
| React.MouseEvent<SVGRectElement, MouseEvent>
| React.MouseEvent<SVGGElement, MouseEvent>,
eventType: BarEvent,
task: BarTask
) => {
switch (event.type) {
case 'mousedown':
setBarEvent(eventType);
setSelectedTask(task);
setInitEventX1Delta(event.nativeEvent.offsetX - task.x1);
event.stopPropagation();
break;
case 'mouseleave':
if (!barEvent) setSelectedTask(null);
break;
case 'mouseenter':
if (!selectedTask) {
setSelectedTask(task);
}
break;
}
};
/**
* Method handles Bar keyboard events
* @param event
* @param task
*/
const handleButtonSVGEvents = async (
event: React.KeyboardEvent<SVGGElement>,
task: BarTask
) => {
if (task.isDisabled) return;
switch (event.key) {
case 'Delete': {
if (onTaskDelete) {
onTaskDelete(task);
}
break;
}
}
};
return (
<>
<g className="arrow" fill={arrowColor} stroke={arrowColor}>
{barTasks.map(task => {
return task.barChildren.map(child => {
return (
<Arrow
key={`Arrow from ${task.id} to ${barTasks[child].id}`}
taskFrom={task}
taskTo={barTasks[child]}
rowHeight={rowHeight}
arrowIndent={arrowIndent}
/>
);
});
})}
</g>
<g className="bar" fontFamily={fontFamily} fontSize={fontSize}>
{barTasks.map(task => {
return (
<Bar
task={task}
arrowIndent={arrowIndent}
isProgressChangeable={!!onProgressChange && !task.isDisabled}
onDoubleClick={onDoubleClick}
isDateChangeable={!!onDateChange && !task.isDisabled}
handleMouseEvents={handleMouseEvents}
handleButtonSVGEvents={handleButtonSVGEvents}
key={task.id}
/>
);
})}
</g>
<g className="toolTip">
{selectedTask && barEvent !== 'end' && barEvent !== 'start' && (
<Tooltip
x={selectedTask.x2 + columnWidth + arrowIndent}
y={selectedTask.y + rowHeight}
task={selectedTask}
fontFamily={fontFamily}
fontSize={fontSize}
getTooltipContent={getTooltipContent}
/>
)}
</g>
</>
);
};
+85
View File
@@ -0,0 +1,85 @@
import React, { useRef } from 'react';
import { ViewMode, GanttProps } from '../../types/public-types';
import { Grid, GridProps } from '../Grid/grid';
import { Calendar, CalendarProps } from '../Calendar/calendar';
import { GanttContent, GanttContentProps } from './gantt-content';
import { ganttDateRange, seedDates } from '../../helpers/date-helper';
export const Gantt: React.SFC<GanttProps> = ({
tasks,
headerHeight = 50,
columnWidth = 60,
rowHeight = 50,
viewMode = ViewMode.Day,
locale = 'en-GB',
barFill = 60,
barCornerRadius = 3,
handleWidth = 8,
timeStep = 300000,
arrowColor = 'grey',
fontFamily = 'Arial, Roboto, Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue',
fontSize = '14px',
arrowIndent = 20,
onDateChange,
onProgressChange,
onDoubleClick,
onTaskDelete,
getTooltipContent,
}) => {
const [startDate, endDate] = ganttDateRange(tasks, viewMode);
const dates = seedDates(startDate, endDate, viewMode);
const svg = useRef<SVGSVGElement | null>(null);
const gridProps: GridProps = {
columnWidth,
gridWidth: dates.length * columnWidth,
tasks,
rowHeight,
headerHeight,
dates,
};
const calendarProps: CalendarProps = {
dates,
locale,
viewMode,
headerHeight,
columnWidth,
fontFamily,
fontSize,
};
const barProps: GanttContentProps = {
tasks,
rowHeight,
barCornerRadius,
columnWidth,
dates,
barFill,
headerHeight,
handleWidth,
timeStep,
arrowColor,
svg,
fontFamily,
fontSize,
arrowIndent,
onDateChange,
onProgressChange,
onDoubleClick,
onTaskDelete,
getTooltipContent,
};
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={columnWidth * dates.length}
height={headerHeight + rowHeight * tasks.length}
ref={svg}
fontFamily={fontFamily}
>
<Grid {...gridProps} />
<Calendar {...calendarProps} />
<GanttContent {...barProps} />
</svg>
);
};
+99
View File
@@ -0,0 +1,99 @@
import React, { ReactChild } from 'react';
import { Task } from '../../types/public-types';
import { addToDate } from '../../helpers/date-helper';
import '../../style.css';
export type GridBodyProps = {
tasks: Task[];
dates: Date[];
gridWidth: number;
rowHeight: number;
headerHeight: number;
columnWidth: number;
};
export const GridBody: React.FC<GridBodyProps> = ({
tasks,
dates,
rowHeight,
headerHeight,
gridWidth,
columnWidth,
}) => {
let y = headerHeight;
let gridRows: ReactChild[] = [];
let rowLines: ReactChild[] = [];
for (const task of tasks) {
gridRows.push(
<rect
key={'Row' + task.id}
x="0"
y={y}
width={gridWidth}
height={rowHeight}
className="GanttGrid-row"
></rect>
);
rowLines.push(
<line
key={'RowLine' + task.id}
x="0"
y1={y + rowHeight}
x2={gridWidth}
y2={y + rowHeight}
className="GanttGrid-rowLine"
></line>
);
y += rowHeight;
}
const now = new Date();
let tickX = 0;
let ticks: ReactChild[] = [];
let today: ReactChild = <></>;
for (let i = 0; i < dates.length; i++) {
const date = dates[i];
ticks.push(
<line
key={date.getTime()}
x1={tickX}
y1={headerHeight}
x2={tickX}
y2={y}
className="GanttGrid-tick"
></line>
);
if (
(i + 1 !== dates.length &&
date.getTime() < now.getTime() &&
dates[i + 1].getTime() >= now.getTime()) ||
//if current date is last
(i !== 0 &&
i + 1 === dates.length &&
date.getTime() < now.getTime() &&
addToDate(
date,
date.getTime() - dates[i - 1].getTime(),
'millisecond'
).getTime() >= now.getTime())
) {
today = (
<rect
x={tickX}
y={0}
width={columnWidth}
height={y}
className="GanttGrid-todayHighlight"
></rect>
);
}
tickX += columnWidth;
}
return (
<>
<g className="rows">{gridRows}</g>
<g className="rowLines">{rowLines}</g>
<g className="ticks">{ticks}</g>
<g className="today">{today}</g>
</>
);
};
+21
View File
@@ -0,0 +1,21 @@
import React from 'react';
import '../../style.css';
export type GridHeaderProps = {
gridWidth: number;
headerHeight: number;
};
export const GridHeader: React.FC<GridHeaderProps> = ({
gridWidth,
headerHeight,
}) => {
return (
<rect
x="0"
y="0"
width={gridWidth}
height={headerHeight}
className="GanttGrid-header"
></rect>
);
};
+13
View File
@@ -0,0 +1,13 @@
import React from "react";
import { GridBody, GridBodyProps } from "./grid-body";
import { GridHeader, GridHeaderProps } from "./grid-header";
export type GridProps = GridBodyProps & GridHeaderProps;
export const Grid: React.FC<GridProps> = (props) => {
return (
<g className="grid">
<GridHeader {...props} />
<GridBody {...props} />
</g>
);
};
+34
View File
@@ -0,0 +1,34 @@
import React from "react";
import { BarTask } from "../../types/bar-task";
type ArrowProps = {
taskFrom: BarTask;
taskTo: BarTask;
rowHeight: number;
arrowIndent: number;
};
export const Arrow: React.FC<ArrowProps> = ({
taskFrom,
taskTo,
rowHeight,
arrowIndent,
}) => {
const indexCompare = taskFrom.index > taskTo.index ? -1 : 1;
const taskToEndPosition = taskTo.y + taskTo.height / 2;
const path = `M ${taskFrom.x2} ${taskFrom.y + taskFrom.height / 2}
h ${arrowIndent}
v ${(indexCompare * rowHeight) / 2}
H ${taskTo.x1 - arrowIndent}
V ${taskToEndPosition}
h ${arrowIndent}`;
const trianglePoints = `${taskTo.x1},${taskToEndPosition}
${taskTo.x1 - 5},${taskToEndPosition - 5}
${taskTo.x1 - 5},${taskToEndPosition + 5}`;
return (
<>
<path strokeWidth="1.5" d={path} fill="none" />
<polygon points={trianglePoints} />
</>
);
};
+69
View File
@@ -0,0 +1,69 @@
import React, { useRef, useEffect, useState } from 'react';
import { Task } from '../../types/public-types';
import '../../style.css';
export type TooltipProps = {
x: number;
y: number;
task: Task;
fontSize: string;
fontFamily: string;
getTooltipContent?: (
task: Task,
fontSize: string,
fontFamily: string
) => JSX.Element;
};
export const Tooltip: React.FC<TooltipProps> = ({
x,
y,
task,
fontSize,
fontFamily,
getTooltipContent = getStandardTooltipContent,
}) => {
const tooltipRef = useRef<HTMLDivElement | null>(null);
const [toolWidth, setToolWidth] = useState(1000);
const [relatedY, setRelatedY] = useState(y);
useEffect(() => {
if (tooltipRef.current) {
const height =
tooltipRef.current.offsetHeight +
tooltipRef.current.offsetHeight * 0.15;
setRelatedY(y - height);
setToolWidth(tooltipRef.current.scrollWidth * 1.1);
}
}, [tooltipRef, y]);
return (
<foreignObject x={x} y={relatedY} width={toolWidth} height={1000}>
<div ref={tooltipRef} className="TooltipDetailsContainer">
{getTooltipContent(task, fontSize, fontFamily)}
</div>
</foreignObject>
);
};
const getStandardTooltipContent = (
task: Task,
fontSize: string,
fontFamily: string
) => {
const style = {
fontSize,
fontFamily,
};
return (
<div className="TooltipDefaultContainer" style={style}>
<b style={{ fontSize: fontSize + 6 }}>{`${
task.name
}: ${task.start.getDate()}-${task.start.getMonth() +
1}-${task.start.getFullYear()} - ${task.end.getDate()}-${task.end.getMonth() +
1}-${task.end.getFullYear()}`}</b>
<p className="TooltipDefaultContainer-paragraph">{`Duration: ${~~(
(task.end.getTime() - task.start.getTime()) /
(1000 * 60 * 60 * 24)
)} day(s)`}</p>
<p className="TooltipDefaultContainer-paragraph">{`Progress: ${task.progress} %`}</p>
</div>
);
};
+195
View File
@@ -0,0 +1,195 @@
import { Task } from "../types/public-types";
import { BarTask } from "../types/bar-task";
export const convertToBarTasks = (
tasks: Task[],
dates: Date[],
dateDelta: number,
columnWidth: number,
rowHeight: number,
taskHeight: number,
headerHeight: number,
barCornerRadius: number,
handleWidth: number
) => {
let barTasks = tasks.map((t, i) => {
return convertToBarTask(
t,
i,
dates,
dateDelta,
columnWidth,
rowHeight,
taskHeight,
headerHeight,
barCornerRadius,
handleWidth
);
});
barTasks = barTasks.map((task, i) => {
const dependencies = task.dependencies || [];
for (let j = 0; j < dependencies.length; j++) {
const dependence = barTasks.findIndex(
(value) => value.id === dependencies[j]
);
if (dependence !== -1) barTasks[dependence].barChildren.push(i);
}
return task;
});
return barTasks;
};
export const convertToBarTask = (
task: Task,
index: number,
dates: Date[],
dateDelta: number,
columnWidth: number,
rowHeight: number,
taskHeight: number,
headerHeight: number,
barCornerRadius: number,
handleWidth: number
): BarTask => {
const x1 = taskXCoordinate(task.start, dates, dateDelta, columnWidth);
const x2 = taskXCoordinate(task.end, dates, dateDelta, columnWidth);
const y = taskYCoordinate(index, rowHeight, taskHeight, headerHeight);
return {
...task,
x1,
x2,
y,
index,
barCornerRadius,
handleWidth,
height: taskHeight,
barChildren: [],
};
};
export const taskXCoordinate = (
xDate: Date,
dates: Date[],
dateDelta: number,
columnWidth: number
) => {
const index = ~~(
(xDate.getTime() -
dates[0].getTime() +
xDate.getTimezoneOffset() -
dates[0].getTimezoneOffset()) /
dateDelta
);
const x = Math.round(
(index +
(xDate.getTime() -
dates[index].getTime() -
xDate.getTimezoneOffset() * 60 * 1000 +
dates[index].getTimezoneOffset() * 60 * 1000) /
dateDelta) *
columnWidth
);
return x;
};
export const taskYCoordinate = (
index: number,
rowHeight: number,
taskHeight: number,
headerHeight: number
) => {
const y = index * rowHeight + headerHeight + (rowHeight - taskHeight) / 2;
return y;
};
export const progressWithByParams = (
taskX1: number,
taskX2: number,
progress: number
) => {
return (taskX2 - taskX1) * progress * 0.01;
};
export const progressByProgressWidth = (
progressWidth: number,
barTask: BarTask
) => {
const barWidth = barTask.x2 - barTask.x1;
const progressPercent = Math.round((progressWidth * 100) / barWidth);
if (progressPercent >= 100) return 100;
else if (progressPercent <= 0) return 0;
else {
return progressPercent;
}
};
export const progressByX = (x: number, task: BarTask) => {
if (x >= task.x2) return 100;
else if (x <= task.x1) return 0;
else {
const barWidth = task.x2 - task.x1;
const progressPercent = Math.round(((x - task.x1) * 100) / barWidth);
return progressPercent;
}
};
export const getProgressPoint = (
progressX: number,
taskY: number,
taskHeight: number
) => {
const point = [
progressX - 5,
taskY + taskHeight,
progressX + 5,
taskY + taskHeight,
progressX,
taskY + taskHeight - 8.66,
];
return point.join(",");
};
export const startByX = (x: number, xStep: number, task: BarTask) => {
if (x >= task.x2 - task.handleWidth * 2) {
x = task.x2 - task.handleWidth * 2;
}
const steps = Math.round((x - task.x1) / xStep);
const additionalXValue = steps * xStep;
const newX = task.x1 + additionalXValue;
return newX;
};
export const endByX = (x: number, xStep: number, task: BarTask) => {
if (x <= task.x1 + task.handleWidth * 2) {
x = task.x1 + task.handleWidth * 2;
}
const steps = Math.round((x - task.x2) / xStep);
const additionalXValue = steps * xStep;
const newX = task.x2 + additionalXValue;
return newX;
};
export const moveByX = (x: number, xStep: number, task: BarTask) => {
const steps = Math.round((x - task.x1) / xStep);
const additionalXValue = steps * xStep;
const newX1 = task.x1 + additionalXValue;
const newX2 = newX1 + task.x2 - task.x1;
return [newX1, newX2];
};
export const dateByX = (
x: number,
taskX: number,
taskDate: Date,
xStep: number,
timeStep: number
) => {
let newDate = new Date(((x - taskX) / xStep) * timeStep + taskDate.getTime());
newDate = new Date(
newDate.getTime() +
(newDate.getTimezoneOffset() - taskDate.getTimezoneOffset()) * 60000
);
return newDate;
};
+150
View File
@@ -0,0 +1,150 @@
import { Task, ViewMode } from '../types/public-types';
type DateHelperScales =
| 'year'
| 'month'
| 'day'
| 'hour'
| 'minute'
| 'second'
| 'millisecond';
export const addToDate = (
date: Date,
quantity: number,
scale: DateHelperScales
) => {
let newDate = new Date(
date.getFullYear() + (scale === 'year' ? quantity : 0),
date.getMonth() + (scale === 'month' ? quantity : 0),
date.getDate() + (scale === 'day' ? quantity : 0),
date.getHours() + (scale === 'hour' ? quantity : 0),
date.getMinutes() + (scale === 'minute' ? quantity : 0),
date.getSeconds() + (scale === 'second' ? quantity : 0),
date.getMilliseconds() + (scale === 'millisecond' ? quantity : 0)
);
return newDate;
};
export const startOfDate = (date: Date, scale: DateHelperScales) => {
const scores = [
'millisecond',
'second',
'minute',
'hour',
'day',
'month',
'year',
];
const shouldReset = (_scale: DateHelperScales) => {
const max_score = scores.indexOf(scale);
return scores.indexOf(_scale) <= max_score;
};
let newDate = new Date(
date.getFullYear(),
shouldReset('year') ? 0 : date.getMonth(),
shouldReset('month') ? 1 : date.getDate(),
shouldReset('day') ? 0 : date.getHours(),
shouldReset('hour') ? 0 : date.getMinutes(),
shouldReset('minute') ? 0 : date.getSeconds(),
shouldReset('second') ? 0 : date.getMilliseconds()
);
return newDate;
};
export const ganttDateRange = (tasks: Task[], viewMode: ViewMode) => {
let newStartDate: Date = tasks[0].start;
let newEndDate: Date = tasks[0].end;
for (let task of tasks) {
if (task.start < newStartDate) {
newStartDate = task.start;
}
if (task.end > newEndDate) {
newEndDate = task.end;
}
}
if (viewMode === ViewMode.Month) {
newStartDate = addToDate(newStartDate, -1, 'month');
newEndDate = addToDate(newEndDate, 1, 'year');
newEndDate = startOfDate(newEndDate, 'year');
} else if (viewMode === ViewMode.Week) {
newStartDate = startOfDate(newStartDate, 'day');
newEndDate = startOfDate(newEndDate, 'day');
newStartDate = addToDate(getMonday(newStartDate), -7, 'day');
newEndDate = addToDate(newEndDate, 1.5, 'month');
} else {
newStartDate = startOfDate(newStartDate, 'day');
newEndDate = startOfDate(newEndDate, 'day');
newStartDate = addToDate(newStartDate, -1, 'day');
newEndDate = addToDate(newEndDate, 19, 'day');
}
return [newStartDate, newEndDate];
};
export const seedDates = (
startDate: Date,
endDate: Date,
viewMode: ViewMode
) => {
let currentDate: Date = new Date(startDate);
let dates: Date[] = [currentDate];
while (currentDate < endDate) {
if (viewMode === ViewMode.Month) {
currentDate = addToDate(currentDate, 1, 'month');
} else if (viewMode === ViewMode.Week) {
currentDate = addToDate(currentDate, 7, 'day');
} else if (viewMode === ViewMode.Day) {
currentDate = addToDate(currentDate, 1, 'day');
} else if (viewMode === ViewMode.HalfDay) {
currentDate = addToDate(currentDate, 12, 'hour');
} else if (viewMode === ViewMode.QuarterDay) {
currentDate = addToDate(currentDate, 6, 'hour');
}
dates.push(currentDate);
}
return dates;
};
export const getLocaleMonth = (date: Date, locale: string) => {
let bottomValue = new Intl.DateTimeFormat(locale, {
month: 'long',
}).format(date);
bottomValue = bottomValue.replace(
bottomValue[0],
bottomValue[0].toLocaleUpperCase()
);
return bottomValue;
};
/**
* Returns monday of current week
* @param date date for modify
*/
const getMonday = (date: Date) => {
const day = date.getDay();
const diff = date.getDate() - day + (day == 0 ? -6 : 1); // adjust when day is sunday
return new Date(date.setDate(diff));
};
export const getWeekNumberISO8601 = (date: Date) => {
let tmpDate = new Date(date.valueOf());
const dayNumber = (tmpDate.getDay() + 6) % 7;
tmpDate.setDate(tmpDate.getDate() - dayNumber + 3);
const firstThursday = tmpDate.valueOf();
tmpDate.setMonth(0, 1);
if (tmpDate.getDay() !== 4) {
tmpDate.setMonth(0, 1 + ((4 - tmpDate.getDay() + 7) % 7));
}
const weekNumber = (
1 + Math.ceil((firstThursday - tmpDate.valueOf()) / 604800000)
).toString();
if (weekNumber.length === 1) {
return `0${weekNumber}`;
} else {
return weekNumber;
}
};
+9
View File
@@ -0,0 +1,9 @@
export { Gantt } from './components/Gantt/gantt';
export {
GanttProps,
Task,
ViewMode,
StylingOption,
DisplayOption,
EventOption,
} from './types/public-types';
+34
View File
@@ -0,0 +1,34 @@
import { Task } from "../types/public-types";
type GanttReduceState = {
ganttTasks: Task[];
};
export type GanttReduceAction = {
type: "update" | "delete";
changedTask?: Task;
};
export function ganttReducer(
state: GanttReduceState,
action: GanttReduceAction
): GanttReduceState {
switch (action.type) {
case "update": {
return {
ganttTasks: state.ganttTasks.map((t) =>
t.id === action.changedTask?.id ? action.changedTask : t
),
};
}
case "delete": {
return {
ganttTasks: state.ganttTasks.filter(
(t) => t.id !== action.changedTask?.id
),
};
}
default:
return state;
}
}
+109
View File
@@ -0,0 +1,109 @@
.GanttBar {
user-select: none;
stroke-width: 0;
}
.GanttBar-wrapper {
cursor: pointer;
outline: none;
}
.GanttBar-wrapper:hover .GanttBar-handle {
visibility: visible;
opacity: 1;
}
.GanttBar-label {
fill: #fff;
text-anchor: middle;
font-weight: lighter;
dominant-baseline: central;
-webkit-touch-callout: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
}
.GanttBar-label-outside {
fill: #555;
text-anchor: start;
}
.GanttBar-handle {
fill: #ddd;
cursor: ew-resize;
opacity: 0;
visibility: hidden;
transition: opacity 0.3s ease;
}
.GanttCalendar-bottomText {
text-anchor: middle;
fill: #333;
-webkit-touch-callout: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
}
.GanttCalendar-topTick {
stroke: #e6e4e4;
}
.GanttCalendar-topText {
text-anchor: middle;
fill: #555;
-webkit-touch-callout: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
}
.GanttGrid-row {
fill: #ffffff;
}
.GanttGrid-row:nth-child(even) {
fill: #f5f5f5;
}
.GanttGrid-header {
fill: #ffffff;
stroke: #e0e0e0;
stroke-width: 1.4;
}
.GanttGrid-rowLine {
stroke: #ebeff2;
}
.GanttGrid-tick {
stroke: #e6e4e4;
}
.GanttGrid-todayHighlight {
fill: #fcf8e3;
opacity: 0.5;
}
.TooltipDefaultContainer {
background: #fff;
padding: 12px;
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.16), 0 3px 6px rgba(0, 0, 0, 0.23);
}
.TooltipDefaultContainer-paragraph {
font-size: 12px;
margin-bottom: 6px;
color: #666;
}
.TooltipDetailsContainer {
display: table;
}
+12
View File
@@ -0,0 +1,12 @@
import { Task } from "./public-types";
export interface BarTask extends Task {
index: number;
x1: number;
x2: number;
y: number;
height: number;
barCornerRadius: number;
handleWidth: number;
barChildren: number[];
}
+71
View File
@@ -0,0 +1,71 @@
export enum ViewMode {
QuarterDay = 'Quarter Day',
HalfDay = 'Half Day',
Day = 'Day',
/** ISO-8601 week */
Week = 'Week',
Month = 'Month',
}
export interface Task {
id: string;
name: string;
start: Date;
end: Date;
/**
* From 0 to 100
*/
progress: number;
styles?: {
backgroundColor?: string;
backgroundSelectedColor?: string;
progressColor?: string;
progressSelectedColor?: string;
};
isDisabled?: boolean;
dependencies?: string[];
}
export interface EventOption {
/**
* Time step value for date changes.
*/
timeStep?: number;
onDoubleClick?: (task: Task) => any;
onDateChange?: (task: Task) => void | Promise<any>;
onProgressChange?: (task: Task) => void | Promise<any>;
onTaskDelete?: (task: Task) => void | Promise<any>;
}
export interface DisplayOption {
viewMode?: ViewMode;
/**
* Display date format. Able formats: ISO 639-2, Java Locale
*/
locale?: string;
}
export interface StylingOption {
headerHeight?: number;
columnWidth?: number;
rowHeight?: number;
barCornerRadius?: number;
handleWidth?: number;
fontFamily?: string;
fontSize?: string;
/**
* How many of row width can be taken by task.
* From 0 to 100
*/
barFill?: number;
arrowColor?: string;
arrowIndent?: number;
getTooltipContent?: (
task: Task,
fontSize: string,
fontFamily: string
) => JSX.Element;
}
export interface GanttProps extends EventOption, DisplayOption, StylingOption {
tasks: Task[];
}