import dayjs, { Dayjs } from "dayjs"; import { LitElement, html, customElement, property, TemplateResult, internalProperty, CSSResult, } from "lit-element"; import { styleMap } from "lit-html/directives/style-map"; import { unsafeHTML } from "lit-html/directives/unsafe-html"; import SimpleBar from "simplebar"; import { SimpleBarStyle } from "./styles/SimpleBar.styles"; import { Event, IEvent } from "./Event"; import { Ressource, IRessource } from "./Ressource"; export { HorizontalResizer } from "./components/horizontal-resizer"; import syncronizeElementsScrolling from "./utils/syncroScroll"; import Selectable from "./utils/selectable"; import { TimelineStyle } from "./styles/Timeline.style"; export interface TimelineOptions { ressources?: Array; items?: Array; } interface TimelineContent { ressources: Array; items: Array; index: number; } interface TimeInterval { start: number; end: number; slots: Array; } type dayjsUnit = "y" | "M" | "d" | "h" | "m" | "s"; export type UnitLegend = { [k in dayjsUnit]: string; }; interface legendItem { colspan: number; title: string; } // TODO add selectable Slot // TODO enable to rearrange between different component. /** * This is a component that can display event in a Timeline. * */ @customElement("jc-timeline") class Timeline extends LitElement { static get styles(): CSSResult[] { return [TimelineStyle, SimpleBarStyle]; } customStyle: string; @internalProperty() // important for the refresh private rows: Array; @internalProperty() // important for the refresh private items: Array; private selectedList: Array; @property({ type: Number }) public ressourceWidth: number; @property({ type: Object }) private _start: Dayjs; @property({ type: String }) public get start(): string { return this._start.toISOString(); } public set start(value: string) { const d = dayjs(value); if (d.isValid()) { this._start = d < this._end ? d : this._end; this.updateLegend(); } else { console.log("Invalid Date :", value); } } private _end: Dayjs; @property({ type: String }) public get end(): string { return this._end.toISOString(); } public set end(value: string) { const d = dayjs(value); if (d.isValid()) { this._end = this._start < d ? d : this._start; this.updateLegend(); } } private _slotDuration = 30; // in minute @property({ type: Number }) public get slotDuration(): number { return this._slotDuration; } public set slotDuration(value: number) { this._slotDuration = value; this.updateLegend(); } private _legendSpan = 2; // in slot count @property({ type: Number }) public get legendSpan(): number { return this._legendSpan; } public set legendSpan(value: number) { this._legendSpan = value; this.updateLegend(); } @property({ type: Number }) private rowHeight = 32; // in px @property({ type: Number }) public slotWidth = 20; // in px @property({ type: String }) private rowsTitle: string; private legendUnitFormat: UnitLegend = { y: "YYYY", M: "MMMM", d: "D", h: "H[h]", m: "m'", s: "s[s]", }; @property({ type: Array }) private legend: Array>; constructor(options: TimelineOptions = {}) { super(); this.rows = []; this.items = []; this._start = dayjs().startOf("day"); this._end = this._start.endOf("day"); this.rowsTitle = "Ressources"; this.ressourceWidth = 200; this.selectedList = []; this.legend = []; this.defaultBackground = ""; this.customStyle = ""; if (options.ressources) { this.addRessources(options.ressources); } if (options.items) { this.addEvents(options.items); } this.updateLegend(); this.render(); } @property({ type: String }) set defaultBackground(value: string) { this.style.setProperty("--default-background", value); } get defaultBackground(): string { return this.style.getPropertyValue("--default-background"); } setLegendUnitFormatAll(legend: Partial): void { this.legendUnitFormat = { ...this.legendUnitFormat, ...legend }; this.updateLegend(); } setLegendUnitFormat(unit: dayjsUnit, format: string): void { this.legendUnitFormat[unit] = format; this.updateLegend(); } addRessources(list: Array): Array { return list.map((r) => this.addRessource(r)); 0; } // Ressource management /** * Add the ressource the the timeline or return the existing ressource with the same id * @param ressource * @returns The Ressource object registered to in the timeline */ addRessource(ressource: IRessource, pos = Infinity): Ressource { const existingRessource = this.getRessourceFromId(ressource.id); if (existingRessource) { return existingRessource; } const r = Ressource.toRessource(ressource); if (r.parent !== undefined) { r.parent = this.getRessourceFromId(r.parent.id) ?? this.addRessource(r.parent); const idx = this.rows.indexOf(r.parent as Ressource); const alreadyChild = r.parent?.children.some((c) => c.id == r.id); if (idx > -1) { if (pos <= idx) { this.rows.splice(idx + 1, 0, r); if (!alreadyChild) r.parent.children = [r, ...r.parent.children]; } else if (pos < idx + r.parent.children.length) { this.rows.splice(pos, 0, r); if (!alreadyChild) r.parent.children.splice(pos - idx, 0, r); } else { this.rows.splice(idx + r.parent.children.length + 1, 0, r); if (!alreadyChild) r.parent.children = [...r.parent.children, r]; } } else { throw new Error("Not able to create ressource parent.\n" + r.id); } } else { if (pos < 0) { this.rows = [r, ...this.rows]; } else { let idx = pos; while (idx < this.rows.length) { if (this.rows[idx].parent == undefined) { this.rows.splice(idx, 0, r); break; } idx++; } if (idx >= this.rows.length) { this.rows = [...this.rows, r]; } } } this.addRessources(r.children); this._updateEventPosition(r); return r; } removeRessourceById(id: string): TimelineContent { return this._removeRessourceById(id); } _removeRessourceById(id: string, depth = 0): TimelineContent { const output: TimelineContent = { ressources: [], items: [], index: 0 }; for (let i = 0; i < this.rows.length; i) { const ressource = this.rows[i]; if (ressource.id === id) { output.index = i; output.ressources.push(ressource); // remove the top level children from it's parent. if (ressource.parent && depth === 0) { ressource.parent.children = ressource.parent.children.filter((o) => o.id !== id); } this.rows.splice(i, 1); } else if (ressource.parentId === id) { const partialOutput = this._removeRessourceById(ressource.id, depth + 1); output.ressources.push(...partialOutput.ressources); output.items.push(...partialOutput.items); } else { i++; } } // Recover deleted items output.items.push(...this.items.filter((i) => i.ressourceId === id)); // Update items list this.items = this.items.filter((i) => i.ressourceId !== id); return output; } getRessources(): Array { return this.rows; } getRessourceFromId(id: string): Ressource | null { const tmp = this.rows.filter((r) => r.id === id); return tmp.length > 0 ? tmp[0] : null; } updateRessource( id: string, key: K, value: Ressource[K] ): Ressource | null { const ressource = this.getRessourceFromId(id); if (ressource) { if (key == "parent") { const content = this.removeRessourceById(id); ressource[key] = value; this.addRessource(ressource); this.addRessources(content.ressources); this.addEvents(content.items); } else { ressource[key] = value; this.rows = this.rows.map((r) => (r.id == ressource.id ? ressource : r)); } this._updateEventPosition(ressource); return ressource; } return null; } setRowsTitle(title: string): void { this.rowsTitle = title; } getEventById(id: string): Event | undefined { return this.items.find((o) => o.id == id); } addEvents(list: Array): Array { return list.map((e) => this.addEvent(e)); } // TimeSlot management /** * Add the event the the timeline or return the existing event with the same id * return undefined if the event ressource is not defined in the timeline * @param event * @returns */ addEvent(event: IEvent): Event | undefined { const existingEvent = this.getEventById(event.id); if (existingEvent) { return existingEvent; } const ressource = this.rows.find((r) => r.id === event.ressourceId); if (ressource === undefined) { return undefined; } const timeslot = Event.toTimeSlot(event); this.items = [...this.items, timeslot]; // Update timeslot status timeslot.isDisplayed = timeslot.end > this._start.toDate() || timeslot.start < this._end.toDate(); this._updateEventPosition(ressource); return timeslot; } removeEventById(id: string): Array { const output = this.items.filter((o) => o.id === id); this.items = this.items.filter((o) => o.id !== id); return output; } updateEventById(id: string): Event | null { const output = this.removeEventById(id); if (output.length > 0) { this.addEvent(output[0]); return output[0]; } else { return null; } } private _updateEventPosition(ressource: Ressource): void { const timeslots = this.items.filter((i) => i.ressourceId === ressource.id); if (timeslots.length === 0) { ressource.height = this.rowHeight + (ressource.collapseChildren ? 5 : 0); return; } const start = this._start.toDate().getTime(); const end = this._end.toDate().getTime(); // List potential interval const points = [start, end]; const populateInterval = (d: Date) => { const t = d.getTime(); if (start < t && t < end && !points.includes(t)) { points.push(t); } }; timeslots.forEach((element) => { populateInterval(element.start); populateInterval(element.end); }); points.sort(); // Count maximum number of interval intersection const intervals: Array = []; for (let i = 0; i < points.length - 1; i++) { const startTime = points[i]; const endTime = points[i + 1]; intervals.push({ start: points[i], end: points[i + 1], slots: timeslots.filter( (slot) => slot.start.getTime() <= startTime && endTime <= slot.end.getTime() ), }); } // Update rows height const lineCount = intervals.reduce((acc, interval) => Math.max(acc, interval.slots.length), 0); ressource.height = this.rowHeight * Math.max(lineCount, 1) + (ressource.collapseChildren ? 5 : 0); // to avoid collapse rows // Solve the offset positioning of all items const sortTimeslots = (a: Event, b: Event): number => { const t = a.start.getTime() - b.start.getTime(); if (t === 0) { const tend = b.end.getTime() - a.end.getTime(); return tend === 0 ? ("" + a.id).localeCompare(b.id) : tend; } return t; }; // Remove all items offset timeslots.forEach((slot) => (slot.offset = -1)); timeslots.sort(sortTimeslots); timeslots[0].offset = 0; const potentialOffset: Array = []; for (let i = 0; i < lineCount; i++) { potentialOffset.push(i); } intervals.forEach((intervals) => { intervals.slots.sort(sortTimeslots); const usedOffset = intervals.slots.map((o) => o.offset).filter((i) => i > -1); const availableOffset = potentialOffset.filter((i) => !usedOffset.includes(i)); intervals.slots.forEach((slot) => { if (slot.offset === -1) { slot.offset = availableOffset.shift() || 0; } }); }); } getEvents(): Array { return this.items; } updateLegend(): void { const legend: Array> = []; const legendUnitList: Array = ["y", "M", "d", "h", "m", "s"]; const legendMinUnitSpan = this.slotDuration * this.legendSpan; const deltaT = this._end.diff(this._start, "m"); const nCol = Math.floor(deltaT / legendMinUnitSpan) + 1; for (const legendUnit of legendUnitList) { const currentDate = dayjs(this._start); const format = this.legendUnitFormat[legendUnit]; let nextColumn = currentDate.add(legendMinUnitSpan, "m"); // Check is the starting and end date can accomodate fews cell of the given unit for this unit of time const isLegendPossible = // Enough time to fit 1 cell AND this._end.diff(this._start, legendUnit) > 0 && // Starting & Ending date have different legend (nextColumn.format(format) !== currentDate.format(format) || // OR 2 consecutive legends can accomodate at least 1 grid cell currentDate.add(1, legendUnit).diff(currentDate, "m") >= legendMinUnitSpan); if (isLegendPossible) { let currentHeader = currentDate.format(format); const row: Array = []; let i = 0; for (let j = 0; j < nCol; j++) { i += this.legendSpan; nextColumn = nextColumn.add(legendMinUnitSpan, "m"); const nextHeader = nextColumn.format(format); if (currentHeader !== nextHeader) { row.push({ colspan: i, title: currentHeader, }); i = 0; currentHeader = nextHeader; } } // IF the last title cover some column add it to the legend if (i > 0) { row.push({ colspan: i, title: currentHeader, }); } legend.push(row); } } this.legend = legend; } private _handleResizeX(e: CustomEvent): void { e.stopPropagation(); this.ressourceWidth += e.detail; if (this.ressourceWidth < 0) { this.ressourceWidth = 0; } } private _grabHeader(e: MouseEvent): void { const root = this.shadowRoot; if (root !== null) { const headerContainer = root.querySelector( ".jc-timeline-grid-title-container" ) as HTMLBaseElement; let lastPosX = e.clientX; const scroll = function (e: MouseEvent) { const scrollLeft = lastPosX - e.clientX; headerContainer.scrollBy({ left: scrollLeft }); lastPosX = e.clientX; }; const mouseUpListener = function (_e: MouseEvent) { window.removeEventListener("mousemove", scroll); window.removeEventListener("mouseup", mouseUpListener); }; window.addEventListener("mousemove", scroll); window.addEventListener("mouseup", mouseUpListener); } } private _getEventResizerHandler(slot: Event, direction: "end" | "start") { return (evt: MouseEvent): void => { evt.stopPropagation(); evt.preventDefault(); const startPos = evt.clientX; const localSlot = slot; const localDir = direction; const initialDate = slot[direction]; const resizeListener = (e: MouseEvent) => { const newDate = dayjs(initialDate) .add(Math.round((e.clientX - startPos) / this.slotWidth) * this.slotDuration, "m") .toDate(); if (direction === "start" ? newDate < localSlot.end : localSlot.start < newDate) { if (localSlot[localDir] !== newDate) { localSlot[localDir] = newDate; this.updateEventById(slot.id); } } }; const mouseUpListener = (_e: MouseEvent): void => { window.removeEventListener("mousemove", resizeListener); window.removeEventListener("mouseup", mouseUpListener); localSlot.moving = false; this.updateEventById(localSlot.id); if (initialDate !== localSlot[localDir]) { const cEvt = new CustomEvent("event-change", { detail: { items: [localSlot] }, }); this.dispatchEvent(cEvt); } }; localSlot.moving = true; window.addEventListener("mousemove", resizeListener); window.addEventListener("mouseup", mouseUpListener); }; } private _getEventGrabHandler( slot: Event, editable: boolean, ressourceEditable: boolean, callback: (e: MouseEvent, wasModified: boolean) => void ) { return (evt: MouseEvent): boolean => { evt.stopPropagation(); evt.preventDefault(); const startPos: number = evt.clientX; let hasChanged = false; const localSlot: Event = slot; // Register all current selected timeslot let localSlots: Array = this.selectedList .filter((s) => s instanceof Event) .map((s) => s as Event); if (!localSlots.includes(localSlot)) { localSlots = [localSlot]; } const startDates = localSlots.map((slot) => slot.start); const endDates = localSlots.map((slot) => slot.end); const updatePosition = editable ? (e: MouseEvent) => { const changeTime = Math.round((e.clientX - startPos) / this.slotWidth) * this.slotDuration; return localSlots .map((slot, index) => { const prevStart = slot.start; slot.start = dayjs(startDates[index]).add(changeTime, "m").toDate(); slot.end = dayjs(endDates[index]).add(changeTime, "m").toDate(); return prevStart.getTime() !== slot.start.getTime(); }) .reduce((prev, curr) => prev || curr); } : (_e: MouseEvent) => { return false; }; const updateRessource = ressourceEditable ? (e: MouseEvent) => { const rowId = this.shadowRoot ?.elementsFromPoint(e.clientX, e.clientY) .find((e) => e.tagName == "TD") ?.parentElement?.getAttribute("row-id"); if (rowId) { const ressourceId = this.rows[Number(rowId)].id; if (ressourceId !== localSlot.ressourceId) { const oldRessource = this.getRessourceFromId(localSlot.ressourceId) as Ressource; localSlot.ressourceId = ressourceId; this._updateEventPosition(oldRessource); return true; } } return false; } : (_e: MouseEvent) => { return false; }; const moveListener = (e: MouseEvent) => { // Evaluate the updatePosition to avoid || collapse const a = updatePosition(e); if (updateRessource(e) || a) { hasChanged = true; this.updateEventById(localSlot.id); } }; const mouseUpListener = (e: MouseEvent) => { window.removeEventListener("mousemove", moveListener); window.removeEventListener("mouseup", mouseUpListener); localSlot.moving = false; this.updateEventById(slot.id); if (hasChanged) { const cEvt = new CustomEvent("event-change", { detail: { items: localSlots }, }); this.dispatchEvent(cEvt); } callback(e, hasChanged); }; localSlot.moving = true; window.addEventListener("mousemove", moveListener); window.addEventListener("mouseup", mouseUpListener); return true; }; } clearSelectedItems(): void { this.items.forEach((selectable) => { selectable.selected = false; this.updateEventById(selectable.id); }); this.rows.forEach((selectable) => (selectable.selected = false)); this.selectedList = []; } private _clearSelectionHandler = (_e: MouseEvent) => { const exclusionZone = document.querySelectorAll("[jc-timeline-keep-select]"); if (Array.from(exclusionZone).some((e) => e.contains(_e.target as Node))) return; this.clearSelectedItems(); const myEvent = new CustomEvent("item-selected", { detail: { items: [] }, bubbles: true, composed: true, }); this.dispatchEvent(myEvent); window.removeEventListener("click", this._clearSelectionHandler); }; private _getEventClickHandler(clickedItem: Selectable) { const item = clickedItem; return (e: MouseEvent, wasModified = false) => { e.stopPropagation(); const idx = this.selectedList.indexOf(item); if (idx > -1) { if (!wasModified) { if (e.ctrlKey) { this.selectedList.splice(idx, 1); item.selected = false; this.updateEventById(item.id); } else { this.clearSelectedItems(); } } } else { if (this.selectedList.length > 0 && !e.ctrlKey) { this.clearSelectedItems(); } item.selected = true; this.selectedList.push(item); this.updateEventById(item.id); } const myEvent = new CustomEvent("item-selected", { detail: { items: this.selectedList }, bubbles: true, composed: true, }); if (this.selectedList.length > 0) { window.addEventListener("click", this._clearSelectionHandler); } this.dispatchEvent(myEvent); }; } firstUpdated(): void { const root = this.shadowRoot; if (root !== null) { const gridContainer = root.querySelector(".jc-timeline-grid-container") as HTMLBaseElement; const simplebar = new SimpleBar(gridContainer).getScrollElement() as HTMLBaseElement; syncronizeElementsScrolling( [simplebar, root.querySelector(".jc-timeline-grid-title-container") as HTMLBaseElement], "h" ); syncronizeElementsScrolling( [simplebar, root.querySelector(".jc-timeline-rows") as HTMLBaseElement], "v" ); } if (this.defaultBackground === "") { this.style.setProperty("--default-background", "SteelBlue"); } } // RENDERING renderTimeslot(evt: Event): TemplateResult { if (!evt.isDisplayed) { return html``; } let rowTop = 0; let ressource: Ressource; let i: number; for (i = 0; i < this.rows.length && this.rows[i].id !== evt.ressourceId; i++) { ressource = this.rows[i]; if (ressource.show) { rowTop += ressource.height ? ressource.height : this.rowHeight; } } ressource = this.rows[i]; const minute2pixel = this.slotWidth / this.slotDuration; const left = dayjs(evt.start).diff(this._start, "m") * minute2pixel; const right = -dayjs(evt.end).diff(this._end, "m") * minute2pixel; const style = { height: this.rowHeight - 4 + "px", top: rowTop + evt.offset * this.rowHeight + "px", left: left + "px", right: right + "px", backgroundColor: "", }; const bgColor = evt.bgColor ? evt.bgColor : ressource.eventBgColor; if (bgColor) { style.backgroundColor = bgColor; } // Show collapsed ressource if (!ressource.show) { style.height = ""; style.top = rowTop - 6 + "px"; return html`
`; } let content: TemplateResult = html`
${evt.title}
${evt.content ? unsafeHTML(evt.content) : ""}`; const resizer = evt.editable === null ? ressource.eventEditable : evt.editable; const editableRessource = evt.ressourceEditable === null ? ressource.eventRessourceEditable : evt.ressourceEditable; if (resizer) { content = html`
${content}
`; } return html`
${content}
`; } private _getCollapseRessourceHandler(item: Ressource): (e: MouseEvent) => void { return (_e: MouseEvent) => { item.collapseChildren = !item.collapseChildren; this._updateEventPosition(item); // Force rows refresh TODO improve this rerendering this.rows = [...this.rows]; }; } private _onRessourceDragStart(item: Ressource): (event: DragEvent) => void { return (event: DragEvent): void => { event.dataTransfer?.setData("text", item.id); }; } private _onRessourceDragEnter(event: DragEvent): void { if (event.target instanceof HTMLElement) { const tgt = event.target; tgt.classList.add("target"); } } private _onRessourceDragLeave(event: DragEvent): void { if (event.target instanceof HTMLElement) { event.target.classList.remove("target"); } } private _onRessourceDrop(event: DragEvent): void { event.preventDefault(); if (event.target instanceof HTMLElement) { event.target.classList.remove("target"); const srcId = event.dataTransfer?.getData("text"); const destinationId = event.target.parentElement?.getAttribute("ressourceId"); if (srcId && destinationId && destinationId !== srcId) { // Check if destination is not child of parent const src = this.getRessourceFromId(srcId) as Ressource; const destination = this.getRessourceFromId(destinationId) as Ressource; if (destination.descendantOf(src)) { return; } // Remove src item from the current Ressource const movedContent = this.removeRessourceById(src.id); // Update the moved ressource position if (event.target.classList.contains("jc-ressource")) { movedContent.ressources[0].parent = destination; } else { movedContent.ressources[0].parent = destination.parent; let idx = this.rows.findIndex((v) => v.id === destinationId); if (event.target.classList.contains("jc-ressource-below")) { idx += 1; while (idx < this.rows.length && this.rows[idx].descendantOf(destination)) { idx += 1; } } const arr = this.rows; this.rows = [...arr.splice(0, idx), src, ...arr]; } // Add moved children and associated slots this.addRessources(movedContent.ressources); this.addEvents(movedContent.items); this.dispatchEvent( new CustomEvent("reorder-ressource", { detail: { ressources: this.rows }, }) ); } } } renderRessource(item: Ressource): TemplateResult { const depth = item.depth; const style = `--depth:${depth};` + (item.height ? `height:${item.height}px;` : ""); const hasChild = item.children.length > 0; const collapseHandler = this._getCollapseRessourceHandler(item); return html`
${Array(depth) .fill(0) .map((_i) => html``)}${hasChild ? html`` : html``} ${item.title}
`; } private renderGridRow(columns: Array, id = "", height = 30): TemplateResult { return html` ${columns.map( (d, i) => html`   ` )} `; } render(): TemplateResult { const nCol = Math.floor(this._end.diff(this._start, "m") / this.slotDuration) + 1; const columns: Array = []; for (let i = 0; i < nCol; i++) { columns.push(this._start.add(this.slotDuration * i, "m")); } const displayedRows = this.rows .map((r, i) => { return { i: i, r: r }; }) .filter((o) => o.r.show); return html`
${this.rowsTitle}
${columns.map((_) => html``)} ${this.legend.map( (arr) => html` ${arr.map((o) => html``)} ` )}
${o.title}
${this.rows.length > 0 ? displayedRows.map((o) => this.renderRessource(o.r)) : html``}
No ressource
${columns.map((_) => html``)} ${this.rows.length > 0 ? displayedRows.map((o) => this.renderGridRow(columns, o.r.id, o.r.height)) : this.renderGridRow(columns)}
${this.items.map((slot) => this.renderTimeslot(slot))}
`; } } export default Timeline;