import * as $ from 'jquery' import * as moment from 'moment' import { isInt, divideDurationByDuration, htmlEscape } from '../util' import InteractiveDateComponent from '../component/InteractiveDateComponent' import BusinessHourRenderer from '../component/renderers/BusinessHourRenderer' import StandardInteractionsMixin from '../component/interactions/StandardInteractionsMixin' import { default as DayTableMixin, DayTableInterface } from '../component/DayTableMixin' import CoordCache from '../common/CoordCache' import UnzonedRange from '../models/UnzonedRange' import ComponentFootprint from '../models/ComponentFootprint' import TimeGridEventRenderer from './TimeGridEventRenderer' import TimeGridHelperRenderer from './TimeGridHelperRenderer' import TimeGridFillRenderer from './TimeGridFillRenderer' /* A component that renders one or more columns of vertical time slots ----------------------------------------------------------------------------------------------------------------------*/ // We mixin DayTable, even though there is only a single row of days // potential nice values for the slot-duration and interval-duration // from largest to smallest let AGENDA_STOCK_SUB_DURATIONS = [ { hours: 1 }, { minutes: 30 }, { minutes: 15 }, { seconds: 30 }, { seconds: 15 } ] export default class TimeGrid extends InteractiveDateComponent { dayDates: DayTableInterface['dayDates'] daysPerRow: DayTableInterface['daysPerRow'] colCnt: DayTableInterface['colCnt'] updateDayTable: DayTableInterface['updateDayTable'] renderHeadHtml: DayTableInterface['renderHeadHtml'] renderBgTrHtml: DayTableInterface['renderBgTrHtml'] bookendCells: DayTableInterface['bookendCells'] getCellDate: DayTableInterface['getCellDate'] view: any // TODO: make more general and/or remove helperRenderer: any dayRanges: any // UnzonedRange[], of start-end of each day slotDuration: any // duration of a "slot", a distinct time segment on given day, visualized by lines snapDuration: any // granularity of time for dragging and selecting snapsPerSlot: any labelFormat: any // formatting string for times running along vertical axis labelInterval: any // duration of how often a label should be displayed for a slot headContainerEl: any // div that hold's the date header colEls: any // cells elements in the day-row background slatContainerEl: any // div that wraps all the slat rows slatEls: any // elements running horizontally across all columns nowIndicatorEls: any colCoordCache: any slatCoordCache: any bottomRuleEl: any // hidden by default contentSkeletonEl: any colContainerEls: any // containers for each column // inner-containers for each column where different types of segs live fgContainerEls: any bgContainerEls: any helperContainerEls: any highlightContainerEls: any businessContainerEls: any // arrays of different types of displayed segments helperSegs: any highlightSegs: any businessSegs: any constructor(view) { super(view) this.processOptions() } // Slices up the given span (unzoned start/end with other misc data) into an array of segments componentFootprintToSegs(componentFootprint) { let segs = this.sliceRangeByTimes(componentFootprint.unzonedRange) let i for (i = 0; i < segs.length; i++) { if (this.isRTL) { segs[i].col = this.daysPerRow - 1 - segs[i].dayIndex } else { segs[i].col = segs[i].dayIndex } } return segs } /* Date Handling ------------------------------------------------------------------------------------------------------------------*/ sliceRangeByTimes(unzonedRange) { let segs = [] let segRange let dayIndex for (dayIndex = 0; dayIndex < this.daysPerRow; dayIndex++) { segRange = unzonedRange.intersect(this.dayRanges[dayIndex]) if (segRange) { segs.push({ startMs: segRange.startMs, endMs: segRange.endMs, isStart: segRange.isStart, isEnd: segRange.isEnd, dayIndex: dayIndex }) } } return segs } /* Options ------------------------------------------------------------------------------------------------------------------*/ // Parses various options into properties of this object processOptions() { let slotDuration = this.opt('slotDuration') let snapDuration = this.opt('snapDuration') let input slotDuration = moment.duration(slotDuration) snapDuration = snapDuration ? moment.duration(snapDuration) : slotDuration this.slotDuration = slotDuration this.snapDuration = snapDuration this.snapsPerSlot = slotDuration / snapDuration // TODO: ensure an integer multiple? // might be an array value (for TimelineView). // if so, getting the most granular entry (the last one probably). input = this.opt('slotLabelFormat') if ($.isArray(input)) { input = input[input.length - 1] } this.labelFormat = input || this.opt('smallTimeFormat') // the computed default input = this.opt('slotLabelInterval') this.labelInterval = input ? moment.duration(input) : this.computeLabelInterval(slotDuration) } // Computes an automatic value for slotLabelInterval computeLabelInterval(slotDuration) { let i let labelInterval let slotsPerLabel // find the smallest stock label interval that results in more than one slots-per-label for (i = AGENDA_STOCK_SUB_DURATIONS.length - 1; i >= 0; i--) { labelInterval = moment.duration(AGENDA_STOCK_SUB_DURATIONS[i]) slotsPerLabel = divideDurationByDuration(labelInterval, slotDuration) if (isInt(slotsPerLabel) && slotsPerLabel > 1) { return labelInterval } } return moment.duration(slotDuration) // fall back. clone } /* Date Rendering ------------------------------------------------------------------------------------------------------------------*/ renderDates(dateProfile) { this.dateProfile = dateProfile this.updateDayTable() this.renderSlats() this.renderColumns() } unrenderDates() { // this.unrenderSlats(); // don't need this because repeated .html() calls clear this.unrenderColumns() } renderSkeleton() { let theme = this.view.calendar.theme this.el.html( '
' + '
' + '' ) this.bottomRuleEl = this.el.find('hr') } renderSlats() { let theme = this.view.calendar.theme this.slatContainerEl = this.el.find('> .fc-slats') .html( // avoids needing ::unrenderSlats() '' + this.renderSlatRowHtml() + '
' ) this.slatEls = this.slatContainerEl.find('tr') this.slatCoordCache = new CoordCache({ els: this.slatEls, isVertical: true }) } // Generates the HTML for the horizontal "slats" that run width-wise. Has a time axis on a side. Depends on RTL. renderSlatRowHtml() { let view = this.view let calendar = view.calendar let theme = calendar.theme let isRTL = this.isRTL let dateProfile = this.dateProfile let html = '' let slotTime = moment.duration(+dateProfile.minTime) // wish there was .clone() for durations let slotIterator = moment.duration(0) let slotDate // will be on the view's first day, but we only care about its time let isLabeled let axisHtml // Calculate the time for each slot while (slotTime < dateProfile.maxTime) { slotDate = calendar.msToUtcMoment(dateProfile.renderUnzonedRange.startMs).time(slotTime) isLabeled = isInt(divideDurationByDuration(slotIterator, this.labelInterval)) axisHtml = '' + (isLabeled ? '' + // for matchCellWidths htmlEscape(slotDate.format(this.labelFormat)) + '' : '' ) + '' html += '' + (!isRTL ? axisHtml : '') + '' + (isRTL ? axisHtml : '') + '' slotTime.add(this.slotDuration) slotIterator.add(this.slotDuration) } return html } renderColumns() { let dateProfile = this.dateProfile let theme = this.view.calendar.theme this.dayRanges = this.dayDates.map(function(dayDate) { return new UnzonedRange( dayDate.clone().add(dateProfile.minTime), dayDate.clone().add(dateProfile.maxTime) ) }) if (this.headContainerEl) { this.headContainerEl.html(this.renderHeadHtml()) } this.el.find('> .fc-bg').html( '' + this.renderBgTrHtml(0) + // row=0 '
' ) this.colEls = this.el.find('.fc-day, .fc-disabled-day') this.colCoordCache = new CoordCache({ els: this.colEls, isHorizontal: true }) this.renderContentSkeleton() } unrenderColumns() { this.unrenderContentSkeleton() } /* Content Skeleton ------------------------------------------------------------------------------------------------------------------*/ // Renders the DOM that the view's content will live in renderContentSkeleton() { let cellHtml = '' let i let skeletonEl for (i = 0; i < this.colCnt; i++) { cellHtml += '' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '' } skeletonEl = this.contentSkeletonEl = $( '
' + '' + '' + cellHtml + '' + '
' + '
' ) this.colContainerEls = skeletonEl.find('.fc-content-col') this.helperContainerEls = skeletonEl.find('.fc-helper-container') this.fgContainerEls = skeletonEl.find('.fc-event-container:not(.fc-helper-container)') this.bgContainerEls = skeletonEl.find('.fc-bgevent-container') this.highlightContainerEls = skeletonEl.find('.fc-highlight-container') this.businessContainerEls = skeletonEl.find('.fc-business-container') this.bookendCells(skeletonEl.find('tr')) // TODO: do this on string level this.el.append(skeletonEl) } unrenderContentSkeleton() { if (this.contentSkeletonEl) { // defensive :( this.contentSkeletonEl.remove() this.contentSkeletonEl = null this.colContainerEls = null this.helperContainerEls = null this.fgContainerEls = null this.bgContainerEls = null this.highlightContainerEls = null this.businessContainerEls = null } } // Given a flat array of segments, return an array of sub-arrays, grouped by each segment's col groupSegsByCol(segs) { let segsByCol = [] let i for (i = 0; i < this.colCnt; i++) { segsByCol.push([]) } for (i = 0; i < segs.length; i++) { segsByCol[segs[i].col].push(segs[i]) } return segsByCol } // Given segments grouped by column, insert the segments' elements into a parallel array of container // elements, each living within a column. attachSegsByCol(segsByCol, containerEls) { let col let segs let i for (col = 0; col < this.colCnt; col++) { // iterate each column grouping segs = segsByCol[col] for (i = 0; i < segs.length; i++) { containerEls.eq(col).append(segs[i].el) } } } /* Now Indicator ------------------------------------------------------------------------------------------------------------------*/ getNowIndicatorUnit() { return 'minute' // will refresh on the minute } renderNowIndicator(date) { // HACK: if date columns not ready for some reason (scheduler) if (!this.colContainerEls) { return } // seg system might be overkill, but it handles scenario where line needs to be rendered // more than once because of columns with the same date (resources columns for example) let segs = this.componentFootprintToSegs( new ComponentFootprint( new UnzonedRange(date, date.valueOf() + 1), // protect against null range false // all-day ) ) let top = this.computeDateTop(date, date) let nodes = [] let i // render lines within the columns for (i = 0; i < segs.length; i++) { nodes.push($('
') .css('top', top) .appendTo(this.colContainerEls.eq(segs[i].col))[0]) } // render an arrow over the axis if (segs.length > 0) { // is the current time in view? nodes.push($('
') .css('top', top) .appendTo(this.el.find('.fc-content-skeleton'))[0]) } this.nowIndicatorEls = $(nodes) } unrenderNowIndicator() { if (this.nowIndicatorEls) { this.nowIndicatorEls.remove() this.nowIndicatorEls = null } } /* Coordinates ------------------------------------------------------------------------------------------------------------------*/ updateSize(totalHeight, isAuto, isResize) { super.updateSize(totalHeight, isAuto, isResize) this.slatCoordCache.build() if (isResize) { this.updateSegVerticals( [].concat(this.eventRenderer.getSegs(), this.businessSegs || []) ) } } getTotalSlatHeight() { return this.slatContainerEl.outerHeight() } // Computes the top coordinate, relative to the bounds of the grid, of the given date. // `ms` can be a millisecond UTC time OR a UTC moment. // A `startOfDayDate` must be given for avoiding ambiguity over how to treat midnight. computeDateTop(ms, startOfDayDate) { return this.computeTimeTop( moment.duration( ms - startOfDayDate.clone().stripTime() ) ) } // Computes the top coordinate, relative to the bounds of the grid, of the given time (a Duration). computeTimeTop(time) { let len = this.slatEls.length let dateProfile = this.dateProfile let slatCoverage = (time - dateProfile.minTime) / this.slotDuration // floating-point value of # of slots covered let slatIndex let slatRemainder // compute a floating-point number for how many slats should be progressed through. // from 0 to number of slats (inclusive) // constrained because minTime/maxTime might be customized. slatCoverage = Math.max(0, slatCoverage) slatCoverage = Math.min(len, slatCoverage) // an integer index of the furthest whole slat // from 0 to number slats (*exclusive*, so len-1) slatIndex = Math.floor(slatCoverage) slatIndex = Math.min(slatIndex, len - 1) // how much further through the slatIndex slat (from 0.0-1.0) must be covered in addition. // could be 1.0 if slatCoverage is covering *all* the slots slatRemainder = slatCoverage - slatIndex return this.slatCoordCache.getTopPosition(slatIndex) + this.slatCoordCache.getHeight(slatIndex) * slatRemainder } // Refreshes the CSS top/bottom coordinates for each segment element. // Works when called after initial render, after a window resize/zoom for example. updateSegVerticals(segs) { this.computeSegVerticals(segs) this.assignSegVerticals(segs) } // For each segment in an array, computes and assigns its top and bottom properties computeSegVerticals(segs) { let eventMinHeight = this.opt('agendaEventMinHeight') let i let seg let dayDate for (i = 0; i < segs.length; i++) { seg = segs[i] dayDate = this.dayDates[seg.dayIndex] seg.top = this.computeDateTop(seg.startMs, dayDate) seg.bottom = Math.max( seg.top + eventMinHeight, this.computeDateTop(seg.endMs, dayDate) ) } } // Given segments that already have their top/bottom properties computed, applies those values to // the segments' elements. assignSegVerticals(segs) { let i let seg for (i = 0; i < segs.length; i++) { seg = segs[i] seg.el.css(this.generateSegVerticalCss(seg)) } } // Generates an object with CSS properties for the top/bottom coordinates of a segment element generateSegVerticalCss(seg) { return { top: seg.top, bottom: -seg.bottom // flipped because needs to be space beyond bottom edge of event container } } /* Hit System ------------------------------------------------------------------------------------------------------------------*/ prepareHits() { this.colCoordCache.build() this.slatCoordCache.build() } releaseHits() { this.colCoordCache.clear() // NOTE: don't clear slatCoordCache because we rely on it for computeTimeTop } queryHit(leftOffset, topOffset): any { let snapsPerSlot = this.snapsPerSlot let colCoordCache = this.colCoordCache let slatCoordCache = this.slatCoordCache if (colCoordCache.isLeftInBounds(leftOffset) && slatCoordCache.isTopInBounds(topOffset)) { let colIndex = colCoordCache.getHorizontalIndex(leftOffset) let slatIndex = slatCoordCache.getVerticalIndex(topOffset) if (colIndex != null && slatIndex != null) { let slatTop = slatCoordCache.getTopOffset(slatIndex) let slatHeight = slatCoordCache.getHeight(slatIndex) let partial = (topOffset - slatTop) / slatHeight // floating point number between 0 and 1 let localSnapIndex = Math.floor(partial * snapsPerSlot) // the snap # relative to start of slat let snapIndex = slatIndex * snapsPerSlot + localSnapIndex let snapTop = slatTop + (localSnapIndex / snapsPerSlot) * slatHeight let snapBottom = slatTop + ((localSnapIndex + 1) / snapsPerSlot) * slatHeight return { col: colIndex, snap: snapIndex, component: this, // needed unfortunately :( left: colCoordCache.getLeftOffset(colIndex), right: colCoordCache.getRightOffset(colIndex), top: snapTop, bottom: snapBottom } } } } getHitFootprint(hit) { let start = this.getCellDate(0, hit.col) // row=0 let time = this.computeSnapTime(hit.snap) // pass in the snap-index let end start.time(time) end = start.clone().add(this.snapDuration) return new ComponentFootprint( new UnzonedRange(start, end), false // all-day? ) } // Given a row number of the grid, representing a "snap", returns a time (Duration) from its start-of-day computeSnapTime(snapIndex) { return moment.duration(this.dateProfile.minTime + this.snapDuration * snapIndex) } getHitEl(hit) { return this.colEls.eq(hit.col) } /* Event Drag Visualization ------------------------------------------------------------------------------------------------------------------*/ // Renders a visual indication of an event being dragged over the specified date(s). // A returned value of `true` signals that a mock "helper" event has been rendered. renderDrag(eventFootprints, seg, isTouch) { let i if (seg) { // if there is event information for this drag, render a helper event if (eventFootprints.length) { this.helperRenderer.renderEventDraggingFootprints(eventFootprints, seg, isTouch) // signal that a helper has been rendered return true } } else { // otherwise, just render a highlight for (i = 0; i < eventFootprints.length; i++) { this.renderHighlight(eventFootprints[i].componentFootprint) } } } // Unrenders any visual indication of an event being dragged unrenderDrag() { this.unrenderHighlight() this.helperRenderer.unrender() } /* Event Resize Visualization ------------------------------------------------------------------------------------------------------------------*/ // Renders a visual indication of an event being resized renderEventResize(eventFootprints, seg, isTouch) { this.helperRenderer.renderEventResizingFootprints(eventFootprints, seg, isTouch) } // Unrenders any visual indication of an event being resized unrenderEventResize() { this.helperRenderer.unrender() } /* Selection ------------------------------------------------------------------------------------------------------------------*/ // Renders a visual indication of a selection. Overrides the default, which was to simply render a highlight. renderSelectionFootprint(componentFootprint) { if (this.opt('selectHelper')) { // this setting signals that a mock helper event should be rendered this.helperRenderer.renderComponentFootprint(componentFootprint) } else { this.renderHighlight(componentFootprint) } } // Unrenders any visual indication of a selection unrenderSelection() { this.helperRenderer.unrender() this.unrenderHighlight() } } TimeGrid.prototype.eventRendererClass = TimeGridEventRenderer TimeGrid.prototype.businessHourRendererClass = BusinessHourRenderer TimeGrid.prototype.helperRendererClass = TimeGridHelperRenderer TimeGrid.prototype.fillRendererClass = TimeGridFillRenderer StandardInteractionsMixin.mixInto(TimeGrid) DayTableMixin.mixInto(TimeGrid)