import React, { CSSProperties, useState, useCallback, useRef, ReactNode, useEffect, useLayoutEffect } from "react" import AutoSizeComponent from "./AutoSizeComponent" import moment, { Moment } from "moment" import { LocalizeString } from "ez-localize" /** Row of a GANTT chart */ export interface GanttChartRow { /** Label to left */ label: string /** Indent number. 0 is top-level */ level: number /** YYYY-MM-DD */ startDate: string | null /** YYYY-MM-DD */ endDate: string | null /** Color for bar/milestone */ color: string } /** Height reserved for horiz scroll bar */ const scrollBarHeight = 20 /** Height of all header parts of the bar section */ const headerHeight = 40 /** Height of each row */ const rowHeight = 21 /** Display an editable GANTT chart */ export function GanttChart(props: { /** Rows to display */ rows: GanttChartRow[] /** Start of display range YYYY-MM-DD */ startDate: string /** End of display range YYYY-MM-DD */ endDate: string /** Override simple plus label for add button */ addRowLabel?: ReactNode /** Add level 0 row to bottom of list */ onAddRow?: () => void /** Insert row at same level above specified row */ onInsertRowAbove?: (rowIndex: number) => void /** Insert row at same level below specified row */ onInsertRowBelow?: (rowIndex: number) => void /** Move row to be next sibling of parent */ onMoveRowLeft?: (rowIndex: number) => void /** Move row to be last child of previous sibling */ onMoveRowRight?: (rowIndex: number) => void /** Move row to be before previous sibling */ onMoveRowUp?: (rowIndex: number) => void /** Move row to be after next sibling */ onMoveRowDown?: (rowIndex: number) => void /** Handle row being clicked on */ onRowClick?: (rowIndex: number) => void /** Insert child row */ onInsertChildRow?: (rowIndex: number) => void /** Remove row */ onRemoveRow?: (rowIndex: number) => void /** Localizer for labels */ T: LocalizeString }) { /** Style of labels on left */ const labelStyle: CSSProperties = { height: rowHeight, paddingTop: 0, whiteSpace: "nowrap", cursor: props.onRowClick ? "pointer" : "arrow" } /** Index of row being hovered over */ const [hoverIndex, setHoverIndex] = useState(null) /** Overall container (for measuring mouse position) */ const containerRef = useRef(null) /** Reset hover when out */ const handleMouseLeave = (ev: React.MouseEvent) => { setHoverIndex(null) } /** Handle mouse moves to hover rows */ const handleMouseMove = (ev: React.MouseEvent) => { if (!containerRef.current) { return } // Do not update if within dropdown let target: HTMLElement | null = ev.target as HTMLElement while (target) { if (target.classList.contains("dropdown-menu")) { return } target = target.parentElement } // Get relative Y const y = ev.clientY - containerRef.current.getBoundingClientRect().top const rowIndex = Math.floor((y - headerHeight) / rowHeight) setHoverIndex(rowIndex >= 0 && rowIndex < props.rows.length ? rowIndex : null) } /** Handle mouse moves to hover rows */ const handleClick = (ev: React.MouseEvent) => { if (!containerRef.current) { return } // Ignore if dropdown let target: HTMLElement | null = ev.target as HTMLElement while (target) { if (target.classList.contains("menu")) { return } target = target.parentElement } // Get relative Y const y = ev.clientY - containerRef.current.getBoundingClientRect().top const rowIndex = Math.floor((y - headerHeight) / rowHeight) if (rowIndex >= 0 && rowIndex < props.rows.length && props.onRowClick) { props.onRowClick(rowIndex) } } /** Render a single label in left pane */ const renderLabel = (row: GanttChartRow, index: number) => { // Determine if can move left (indented and previous is previous level) const canMoveLeft = props.onMoveRowLeft && row.level > 0 && index > 0 && props.rows[index - 1].level == row.level - 1 // Determine if can move right (previous is same level) const canMoveRight = props.onMoveRowRight && index > 0 && props.rows[index - 1].level == row.level // Determine if can move up (exists previous of same level before one of previous level) let canMoveUp: boolean = false for (let i = index - 1; i >= 0; i--) { if (props.rows[i].level < row.level) { break } if (props.rows[i].level == row.level) { canMoveUp = props.onMoveRowUp != null break } } // Determine if can move down (exists next of same level before one of next level) let canMoveDown: boolean = false for (let i = index + 1; i < props.rows.length; i++) { if (props.rows[i].level < row.level) { break } if (props.rows[i].level == row.level) { canMoveDown = props.onMoveRowDown != null break } } // Determine if dropdown menu should be shown const showMenu = canMoveLeft || canMoveRight || canMoveUp || canMoveDown || props.onInsertRowBelow != null || props.onInsertRowAbove != null || props.onInsertChildRow != null || props.onRemoveRow != null return (
{ if (props.onRowClick) { props.onRowClick(index) } }} > {row.label} {showMenu ? ( ) : null}
) } return (
{/* Background highlight hovered row */}
{hoverIndex != null ? (
) : null}
{props.rows.map(renderLabel)} {props.onAddRow ? ( ) : null}
{(size: any) => { if (!size.width || !size.height) { // Placeholder until width is known return
} return ( ) }}
) } /** Area of the Gantt chart that contains the bars */ function GanttBarArea(props: { rows: GanttChartRow[] /** YYYY-MM-DD */ startDate: string /** YYYY-MM-DD */ endDate: string /** Width of the area in pixels */ width: number /** Height of the area in pixels */ height: number onRowClick?: (rowIndex: number) => void }) { /** Container (for measuring mouse position) */ const containerRef = useRef(null) /** Amount to scroll by in next render */ const scrollLeftBy = useRef(0) // Expand to nearest month const startDate = moment(props.startDate, "YYYY-MM-DD").startOf("month") const endDate = moment(props.endDate, "YYYY-MM-DD").endOf("month") // Calculate total days const totalDays = endDate.diff(startDate, "days") const initialScale = props.width / totalDays // Set initial scale const [scale, setScale] = useState(initialScale) /** Function to convert a date into pixels at current scale */ const dateToPx = useCallback( (date: Moment) => { return Math.floor(date.diff(startDate, "days") * scale) + 0.5 }, [scale, props.startDate] ) /** Allow zooming using mouse wheel */ const handleWheel = (ev: React.WheelEvent) => { if (!containerRef.current) { return } let newScale: number if (ev.deltaY > 0) { // Prevent scaling down newScale = Math.max(initialScale, scale / 1.1) } else if (ev.deltaY < 0) { newScale = scale * 1.1 } else { return } // Set scale setScale(newScale) // Get relative X to keep in the same place const x = ev.clientX - containerRef.current.getBoundingClientRect().left + containerRef.current.scrollLeft // Determine offset needed to keep mouse position in same place const diffX = (x / scale) * newScale - x scrollLeftBy.current = diffX } // Perform scrolling immediately useLayoutEffect(() => { if (scrollLeftBy.current != 0 && containerRef.current) { containerRef.current.scrollBy({ left: scrollLeftBy.current }) scrollLeftBy.current = 0 } }) /** Draw a bar of the GANTT chart */ const renderBar = (row: GanttChartRow, index: number) => { // Draw bars for actual ranges if (row.startDate && row.endDate && row.startDate != row.endDate) { const rowStartDate = moment(row.startDate, "YYYY-MM-DD") const rowEndDate = moment(row.endDate, "YYYY-MM-DD") return ( ) } // Diamonds for single dates else if (row.startDate || row.endDate) { const rowDate = moment(row.startDate || row.endDate, "YYYY-MM-DD") const x = dateToPx(rowDate) const y = headerHeight + rowHeight * index + rowHeight / 2 const size = 7 return ( ) } else { return null } } const todayPx = dateToPx(moment()) return (
{props.rows.map(renderBar)}
) } /** Display scale at top for each year */ function YearScale(props: { startDate: Moment; endDate: Moment; dateToPx: (date: Moment) => number; height: number }) { const { startDate, endDate } = props const date = moment(startDate) /** Start and end of each segment to draw */ const segs: [Moment, Moment][] = [] while (date.isBefore(endDate)) { const itemStart = moment(date) date.add(1, "years") const itemEnd = moment(date) if (itemEnd.isAfter(endDate)) { segs.push([itemStart, endDate]) } else { segs.push([itemStart, itemEnd]) } } return ( {segs.map((seg, i) => { const left = props.dateToPx(seg[0]) const right = props.dateToPx(seg[1]) return ( {seg[0].format("YYYY")} ) })} ) } /** Display scale at top for each month */ function MonthScale(props: { startDate: Moment; endDate: Moment; dateToPx: (date: Moment) => number; height: number }) { const { startDate, endDate } = props const date = moment(startDate) /** Start and end of each segment to draw */ const segs: [Moment, Moment][] = [] while (date.isBefore(endDate)) { const itemStart = moment(date) date.add(1, "months") const itemEnd = moment(date) if (itemEnd.isAfter(endDate)) { segs.push([itemStart, endDate]) } else { segs.push([itemStart, itemEnd]) } } return ( {segs.map((seg, i) => { const left = props.dateToPx(seg[0]) const right = props.dateToPx(seg[1]) return ( {seg[0].format("MMM")} ) })} ) } /** Display scale at top for each week or day (if scale permits) */ function DayWeekScale(props: { startDate: Moment endDate: Moment dateToPx: (date: Moment) => number height: number scale: number }) { const { startDate, endDate } = props const date = moment(startDate).startOf("week") /** Start and end of each segment to draw */ const segs: [Moment, Moment][] = [] // Display nothing if too zoomed out if (props.scale < 1.6) { return null } while (date.isBefore(endDate)) { const itemStart = moment(date) // Use day if scale permits date.add(1, props.scale > 15 ? "days" : "weeks") const itemEnd = moment(date) // Weeks should not extend outside of range if (!itemEnd.isAfter(endDate) && !itemStart.isBefore(startDate)) { segs.push([itemStart, itemEnd]) } } return ( {segs.map((seg, i) => { const left = props.dateToPx(seg[0]) const right = props.dateToPx(seg[1]) return ( {seg[0].format("DD")} ) })} ) }