Jelajahi Sumber

timecol event placement

Adam Shaw 4 tahun lalu
induk
melakukan
c6515ed862
2 mengubah file dengan 274 tambahan dan 211 penghapusan
  1. 64 42
      packages/timegrid/src/TimeCol.tsx
  2. 210 169
      packages/timegrid/src/event-placement.ts

+ 64 - 42
packages/timegrid/src/TimeCol.tsx

@@ -1,11 +1,11 @@
 import {
   Ref, DateMarker, BaseComponent, createElement, EventSegUiInteractionState, Seg, getSegMeta,
   DateRange, Fragment, DayCellRoot, NowIndicatorRoot, BgEvent, renderFill,
-  DateProfile, config, buildEventRangeKey, sortEventSegs,
+  DateProfile, config, buildEventRangeKey, sortEventSegs, SegInput,
 } from '@fullcalendar/common'
 import { TimeColsSeg } from './TimeColsSeg'
 import { TimeColsSlatsCoords } from './TimeColsSlatsCoords'
-import { computeSegCoords, computeSegVerticals } from './event-placement'
+import { computeFgSegPlacements, TimeColSegRect } from './event-placement'
 import { TimeColEvent } from './TimeColEvent'
 import { TimeColMisc } from './TimeColMisc'
 
@@ -122,10 +122,10 @@ export class TimeCol extends BaseComponent<TimeColProps> {
   }
 
   renderPrintFgSegs(segs: TimeColsSeg[]) {
-    let { props, context } = this
+    let { props } = this
 
     // not DRY
-    segs = sortEventSegs(segs, context.options.eventOrder) as TimeColsSeg[]
+    segs = sortEventSegs(segs, this.context.options.eventOrder) as TimeColsSeg[]
 
     return segs.map((seg) => (
       <div
@@ -152,25 +152,27 @@ export class TimeCol extends BaseComponent<TimeColProps> {
     isResizing?: boolean,
     isDateSelecting?: boolean,
   ) {
-    let { context, props } = this
+    let { props } = this
 
-    // assigns TO THE SEGS THEMSELVES
-    // also, receives resorted array
-    segs = computeSegCoords(segs, props.date, props.slatCoords, context.options.eventMinHeight, context.options.eventOrder) as TimeColsSeg[]
+    segs = sortEventSegs(segs, this.context.options.eventOrder) as TimeColsSeg[]
+    let segInputs = this.buildSegInputs(segs)
+    let segRects = computeFgSegPlacements(segInputs)
 
-    return segs.map((seg) => {
+    return segRects.map((segRect) => {
+      let seg = segs[segRect.segInput.index]
       let instanceId = seg.eventRange.instance.instanceId
       let isMirror = isDragging || isResizing || isDateSelecting
-      let positionCss = isMirror
-        // will span entire column width
+      let positionCss = {
+        ...this.computeSegTopBottomCss(segRect.segInput),
+        // mirrors will span entire column width
         // also, won't assign z-index, which is good, fc-event-mirror will overpower other harnesses
-        ? { left: 0, right: 0, ...this.computeSegTopBottomCss(seg) }
-        : this.computeFgSegPositionCss(seg)
+        ...(isMirror ? { left: 0, right: 0 } : this.computeSegLeftRightCss(segRect))
+      }
 
       return (
         <div
           className={'fc-timegrid-event-harness' + (seg.level > 0 ? ' fc-timegrid-event-harness-inset' : '')}
-          key={instanceId}
+          key={instanceId + ':' + segRect.partIndex}
           style={{
             visibility: segIsInvisible[instanceId] ? 'hidden' : ('' as any),
             ...positionCss,
@@ -190,21 +192,41 @@ export class TimeCol extends BaseComponent<TimeColProps> {
     })
   }
 
+  buildSegInputs(segs: TimeColsSeg[]): SegInput[] {
+    let { date, slatCoords } = this.props
+    let { eventMinHeight } = this.context.options
+    let segInputs: SegInput[] = []
+
+    for (let i = 0; i < segs.length; i++) {
+      let seg = segs[i]
+      let spanStart = slatCoords.computeDateTop(seg.start, date)
+      let spanEnd = Math.max(
+        spanStart + (eventMinHeight || 0), // yuck
+        slatCoords.computeDateTop(seg.end, date),
+      )
+      segInputs.push({ index: i, spanStart, spanEnd, thickness: 1 })
+    }
+
+    return segInputs
+  }
+
   renderFillSegs(segs: TimeColsSeg[], fillType: string) {
-    let { context, props } = this
+    let { props } = this
 
     if (!props.slatCoords) { return null }
 
-    // BAD: assigns TO THE SEGS THEMSELVES
-    computeSegVerticals(segs, props.date, props.slatCoords, context.options.eventMinHeight)
+    let segInputs = this.buildSegInputs(segs)
 
-    let children = segs.map((seg) => (
-      <div key={buildEventRangeKey(seg.eventRange)} className="fc-timegrid-bg-harness" style={this.computeSegTopBottomCss(seg)}>
-        {fillType === 'bg-event' ?
-          <BgEvent seg={seg} {...getSegMeta(seg, props.todayRange, props.nowDate)} /> :
-          renderFill(fillType)}
-      </div>
-    ))
+    let children = segInputs.map((segInput) => {
+      let seg = segs[segInput.index]
+      return (
+        <div key={buildEventRangeKey(seg.eventRange)} className="fc-timegrid-bg-harness" style={this.computeSegTopBottomCss(segInput)}>
+          {fillType === 'bg-event' ?
+            <BgEvent seg={seg} {...getSegMeta(seg, props.todayRange, props.nowDate)} /> :
+            renderFill(fillType)}
+        </div>
+      )
+    })
 
     return <Fragment>{children}</Fragment>
   }
@@ -234,45 +256,45 @@ export class TimeCol extends BaseComponent<TimeColProps> {
     ))
   }
 
-  computeFgSegPositionCss(seg) {
+  computeSegTopBottomCss(segInput: SegInput) {
+    return {
+      top: segInput.spanStart,
+      bottom: -segInput.spanEnd,
+    }
+  }
+
+  computeSegLeftRightCss(segRect: TimeColSegRect) {
     let { isRtl, options } = this.context
     let shouldOverlap = options.slotEventOverlap
-    let backwardCoord = seg.backwardCoord // the left side if LTR. the right side if RTL. floating-point
-    let forwardCoord = seg.forwardCoord // the right side if LTR. the left side if RTL. floating-point
+    let nearCoord = segRect.levelCoord // the left side if LTR. the right side if RTL. floating-point
+    let farCoord = segRect.levelCoord + segRect.thickness // the right side if LTR. the left side if RTL. floating-point
     let left // amount of space from left edge, a fraction of the total width
     let right // amount of space from right edge, a fraction of the total width
 
     if (shouldOverlap) {
       // double the width, but don't go beyond the maximum forward coordinate (1.0)
-      forwardCoord = Math.min(1, backwardCoord + (forwardCoord - backwardCoord) * 2)
+      farCoord = Math.min(1, nearCoord + (farCoord - nearCoord) * 2)
     }
 
     if (isRtl) {
-      left = 1 - forwardCoord
-      right = backwardCoord
+      left = 1 - farCoord
+      right = nearCoord
     } else {
-      left = backwardCoord
-      right = 1 - forwardCoord
+      left = nearCoord
+      right = 1 - farCoord
     }
 
     let props = {
-      zIndex: seg.level + 1, // convert from 0-base to 1-based
+      zIndex: segRect.zCoord + 1, // convert from 0-base to 1-based
       left: left * 100 + '%',
       right: right * 100 + '%',
     }
 
-    if (shouldOverlap && seg.forwardPressure) {
+    if (shouldOverlap && segRect.forwardPressure) {
       // add padding to the edge so that forward stacked events don't cover the resizer's icon
       props[isRtl ? 'marginLeft' : 'marginRight'] = 10 * 2 // 10 is a guesstimate of the icon's width
     }
 
-    return { ...props, ...this.computeSegTopBottomCss(seg) }
-  }
-
-  computeSegTopBottomCss(seg) {
-    return {
-      top: seg.top,
-      bottom: -seg.bottom,
-    }
+    return props
   }
 }

+ 210 - 169
packages/timegrid/src/event-placement.ts

@@ -1,213 +1,254 @@
-import { Seg, DateMarker, buildSegCompareObj, compareByFieldSpecs, sortEventSegs, OrderSpec, EventApi } from '@fullcalendar/common'
-import { TimeColsSlatsCoords } from './TimeColsSlatsCoords'
-
-// UNFORTUNATELY, assigns results to the top/bottom/level/forwardCoord/backwardCoord props of the actual segs.
-// TODO: return hash (by instanceId) of results
-
-export function computeSegCoords(
-  segs: Seg[],
-  dayDate: DateMarker,
-  slatCoords: TimeColsSlatsCoords,
-  eventMinHeight: number,
-  eventOrderSpecs: OrderSpec<EventApi>[],
-) {
-  computeSegVerticals(segs, dayDate, slatCoords, eventMinHeight)
-  return computeSegHorizontals(segs, eventOrderSpecs) // requires top/bottom from computeSegVerticals
+import {
+  SegEntry,
+  SegHierarchy,
+  SegRect,
+  buildEntryKey,
+  getEntrySpanEnd,
+  binarySearch,
+  SegInput,
+} from '@fullcalendar/common'
+
+interface SegNode extends SegEntry {
+  nextLevelNodes: SegNode[] // with highest-pressure first
 }
 
-// For each segment in an array, computes and assigns its top and bottom properties
-export function computeSegVerticals(segs: Seg[], dayDate: DateMarker, slatCoords: TimeColsSlatsCoords, eventMinHeight: number) {
-  for (let seg of segs) {
-    seg.top = slatCoords.computeDateTop(seg.start, dayDate)
-    seg.bottom = Math.max(
-      seg.top + (eventMinHeight || 0), // yuck
-      slatCoords.computeDateTop(seg.end, dayDate),
-    )
-  }
+type SegNodeAndPressure = [ SegNode, number ]
+
+interface SegSiblingRange { // will ALWAYS have span of 1 or more items. if not, will be null
+  level: number
+  lateralStart: number
+  lateralEnd: number
 }
 
-// Given an array of segments that are all in the same column, sets the backwardCoord and forwardCoord on each.
-// Assumed the segs are already ordered.
-// NOTE: Also reorders the given array by date!
-function computeSegHorizontals(segs: Seg[], eventOrderSpecs: OrderSpec<EventApi>[]) {
-  // IMPORTANT TO CLEAR OLD RESULTS :(
-  for (let seg of segs) {
-    seg.level = null
-    seg.forwardCoord = null
-    seg.backwardCoord = null
-    seg.forwardPressure = null
-  }
+export interface TimeColSegRect extends SegRect {
+  zCoord: number
+  forwardPressure: number // a number of nodes in longest path to lowest level
+}
 
-  segs = sortEventSegs(segs, eventOrderSpecs)
+export function computeFgSegPlacements(segInputs: SegInput[]): TimeColSegRect[] {
+  let hierarchy = new SegHierarchy()
+  hierarchy.addSegs(segInputs)
+  let web = buildWeb(hierarchy)
+  web = stretchWeb(web, 1) // all levelCoords/thickness will have 0.0-1.0
+  return webToRects(web)
+}
 
-  let level0
-  let levels = buildSlotSegLevels(segs)
-  computeForwardSlotSegs(levels)
+function buildWeb(hierarchy: SegHierarchy): SegNode[] {
+  const { entriesByLevel } = hierarchy
 
-  if ((level0 = levels[0])) {
-    for (let seg of level0) {
-      computeSlotSegPressures(seg)
-    }
+  const buildNode = cacheable(
+    (level: number, lateral: number) => level + ':' + lateral,
+    (level: number, lateral: number): SegNodeAndPressure => {
+      let siblingRange = findNextLevelSegs(hierarchy, level, lateral)
+      let nextLevelRes = buildNodes(siblingRange, buildNode)
+      let entry = entriesByLevel[level][lateral]
 
-    for (let seg of level0) {
-      computeSegForwardBack(seg, 0, 0, eventOrderSpecs)
+      return [
+        { ...entry, nextLevelNodes: nextLevelRes[0] },
+        entry.thickness + nextLevelRes[1] // the pressure builds
+      ]
     }
-  }
-
-  return segs
+  )
+
+  return buildNodes(
+    entriesByLevel.length
+      ? { level: 0, lateralStart: 0, lateralEnd: entriesByLevel[0].length }
+      : null,
+    buildNode
+  )[0]
 }
 
-// Builds an array of segments "levels". The first level will be the leftmost tier of segments if the calendar is
-// left-to-right, or the rightmost if the calendar is right-to-left. Assumes the segments are already ordered by date.
-function buildSlotSegLevels(segs: Seg[]) {
-  let levels = []
-  let i
-  let seg
-  let j
-
-  for (i = 0; i < segs.length; i += 1) {
-    seg = segs[i]
-
-    // go through all the levels and stop on the first level where there are no collisions
-    for (j = 0; j < levels.length; j += 1) {
-      if (!computeSlotSegCollisions(seg, levels[j]).length) {
-        break
-      }
-    }
-
-    seg.level = j;
-    (levels[j] || (levels[j] = [])).push(seg)
+function buildNodes(
+  siblingRange: SegSiblingRange | null,
+  buildNode: (level: number, lateral: number) => SegNodeAndPressure
+): [SegNode[], number] { // number is maxPressure
+  if (!siblingRange) {
+    return [[], 0]
   }
 
-  return levels
-}
+  let { level, lateralStart, lateralEnd } = siblingRange
+  let lateral = lateralStart
+  let pairs: SegNodeAndPressure[] = []
 
-// Find all the segments in `otherSegs` that vertically collide with `seg`.
-// Append into an optionally-supplied `results` array and return.
-function computeSlotSegCollisions(seg: Seg, otherSegs: Seg[], results = []) {
-  for (let i = 0; i < otherSegs.length; i += 1) {
-    if (isSlotSegCollision(seg, otherSegs[i])) {
-      results.push(otherSegs[i])
-    }
+  while (lateral < lateralEnd) {
+    pairs.push(buildNode(level, lateral))
+    lateral++
   }
 
-  return results
-}
+  pairs.sort(cmpDescPressures)
 
-// Do these segments occupy the same vertical space?
-function isSlotSegCollision(seg1: Seg, seg2: Seg) {
-  return seg1.bottom > seg2.top && seg1.top < seg2.bottom
+  return [
+    pairs.map(extractNode),
+    pairs[0][1] // first item's pressure
+  ]
 }
 
-// For every segment, figure out the other segments that are in subsequent
-// levels that also occupy the same vertical space. Accumulate in seg.forwardSegs
-function computeForwardSlotSegs(levels) {
-  let i
-  let level
-  let j
-  let seg
-  let k
-
-  for (i = 0; i < levels.length; i += 1) {
-    level = levels[i]
+function cmpDescPressures(a: SegNodeAndPressure, b: SegNodeAndPressure) { // sort pressure high -> low
+  return b[1] - a[1]
+}
 
-    for (j = 0; j < level.length; j += 1) {
-      seg = level[j]
+function extractNode(a: SegNodeAndPressure): SegNode {
+  return a[0]
+}
 
-      seg.forwardSegs = []
-      for (k = i + 1; k < levels.length; k += 1) {
-        computeSlotSegCollisions(seg, levels[k], seg.forwardSegs)
-      }
+function findNextLevelSegs(hierarchy: SegHierarchy, subjectLevel: number, subjectLateral: number): SegSiblingRange | null {
+  let { levelCoords, entriesByLevel } = hierarchy
+  let subjectEntry = entriesByLevel[subjectLevel][subjectLateral]
+  let afterSubject = levelCoords[subjectLevel] + subjectEntry.thickness
+  let levelCnt = levelCoords.length
+  let level = subjectLevel
+
+  // skip past levels that are too high up
+  for (; level < levelCnt && levelCoords[level] < afterSubject; level++) ; // do nothing
+
+  for (; level < levelCnt; level++) {
+    let entries = entriesByLevel[level]
+    let entry: SegEntry
+    let searchIndex = binarySearch(entries, subjectEntry.spanStart, getEntrySpanEnd)
+    let lateralStart = searchIndex[0] + searchIndex[1] // if exact match (which doesn't collide), go to next one
+    let lateralEnd = lateralStart
+
+    while ( // loop through entries that horizontally intersect
+      (entry = entries[lateralEnd]) && // but not past the whole seg list
+      entry.spanStart < subjectEntry.spanEnd
+    ) { lateralEnd++ }
+
+    if (lateralStart < lateralEnd) {
+      return { level, lateralStart, lateralEnd }
     }
   }
+
+  return null
 }
 
-// Figure out which path forward (via seg.forwardSegs) results in the longest path until
-// the furthest edge is reached. The number of segments in this path will be seg.forwardPressure
-function computeSlotSegPressures(seg: Seg) {
-  let forwardSegs = seg.forwardSegs
-  let forwardPressure = 0
-  let i
-  let forwardSeg
-
-  if (seg.forwardPressure == null) { // not already computed
-    for (i = 0; i < forwardSegs.length; i += 1) {
-      forwardSeg = forwardSegs[i]
-
-      // figure out the child's maximum forward path
-      computeSlotSegPressures(forwardSeg)
-
-      // either use the existing maximum, or use the child's forward pressure
-      // plus one (for the forwardSeg itself)
-      forwardPressure = Math.max(
-        forwardPressure,
-        1 + forwardSeg.forwardPressure,
-      )
+function stretchWeb(topLevelNodes: SegNode[], totalThickness: number): SegNode[] {
+  const stretchNode = cacheable(
+    (node: SegNode, startCoord: number, prevThickness: number) => buildEntryKey(node),
+    (node: SegNode, startCoord: number, prevThickness: number): [number, SegNode] => { // [startCoord, node]
+      let { nextLevelNodes, thickness } = node
+      let allThickness = thickness + prevThickness
+      let thicknessFraction = thickness / allThickness
+      let endCoord: number
+      let newChildren: SegNode[] = []
+
+      if (!nextLevelNodes.length) {
+        endCoord = totalThickness
+      } else {
+        for (let childNode of nextLevelNodes) {
+          if (endCoord === undefined) {
+            let res = stretchNode(childNode, startCoord, allThickness)
+            endCoord = res[0]
+            newChildren.push(res[1])
+          } else {
+            let res = stretchNode(childNode, endCoord, 0)
+            newChildren.push(res[1])
+          }
+        }
+      }
+
+      let newThickness = (endCoord - startCoord) * thicknessFraction
+      return [endCoord - newThickness, {
+        ...node,
+        thickness: newThickness,
+        nextLevelNodes: newChildren
+      }]
     }
+  )
 
-    seg.forwardPressure = forwardPressure
-  }
+  return topLevelNodes.map((node: SegNode) => stretchNode(node, 0, 0)[1])
 }
 
-// Calculate seg.forwardCoord and seg.backwardCoord for the segment, where both values range
-// from 0 to 1. If the calendar is left-to-right, the seg.backwardCoord maps to "left" and
-// seg.forwardCoord maps to "right" (via percentage). Vice-versa if the calendar is right-to-left.
-//
-// The segment might be part of a "series", which means consecutive segments with the same pressure
-// who's width is unknown until an edge has been hit. `seriesBackwardPressure` is the number of
-// segments behind this one in the current series, and `seriesBackwardCoord` is the starting
-// coordinate of the first segment in the series.
-function computeSegForwardBack(seg: Seg, seriesBackwardPressure, seriesBackwardCoord, eventOrderSpecs) {
-  let forwardSegs = seg.forwardSegs
-  let i
-
-  if (seg.forwardCoord == null) { // not already computed
-    if (!forwardSegs.length) {
-      // if there are no forward segments, this segment should butt up against the edge
-      seg.forwardCoord = 1
-    } else {
-      // sort highest pressure first
-      sortForwardSegs(forwardSegs, eventOrderSpecs)
-
-      // this segment's forwardCoord will be calculated from the backwardCoord of the
-      // highest-pressure forward segment.
-      computeSegForwardBack(forwardSegs[0], seriesBackwardPressure + 1, seriesBackwardCoord, eventOrderSpecs)
-      seg.forwardCoord = forwardSegs[0].backwardCoord
+// not sorted in any particular order
+function webToRects(topLevelNodes: SegNode[]): TimeColSegRect[] {
+  let rects: TimeColSegRect[] = []
+  let partIndexHash: { [segId: string]: number } = {}
+  let zCoord = 0
+
+  const processNode = cacheable(
+    (node: SegNode, levelCoord: number) => buildEntryKey(node),
+    (node: SegNode, levelCoord: number) => { // returns forwardPressure
+      let segIndex = node.segInput.index
+      let partIndex = (partIndexHash[segIndex] = (partIndexHash[segIndex] || 0) + 1)
+      let rect: TimeColSegRect = {
+        ...node,
+        partIndex,
+        levelCoord,
+        zCoord,
+        forwardPressure: 0 // will assign after recursing
+      }
+      zCoord++
+      rects.push(rect)
+      let forwardPressure = processNodes(node.nextLevelNodes, levelCoord + node.thickness) + 1
+      rect.forwardPressure = forwardPressure
+      return forwardPressure
     }
+  )
 
-    // calculate the backwardCoord from the forwardCoord. consider the series
-    seg.backwardCoord = seg.forwardCoord -
-      (seg.forwardCoord - seriesBackwardCoord) / // available width for series
-      (seriesBackwardPressure + 1) // # of segments in the series
+  function processNodes(nodes: SegNode[], levelCoord: number) { // returns forwardPressure
+    let forwardPressure = 0
 
-    // use this segment's coordinates to computed the coordinates of the less-pressurized
-    // forward segments
-    for (i = 0; i < forwardSegs.length; i += 1) {
-      computeSegForwardBack(forwardSegs[i], 0, seg.forwardCoord, eventOrderSpecs)
+    for (let node of nodes) {
+      forwardPressure = Math.max(processNode(node, levelCoord), forwardPressure)
     }
+
+    return forwardPressure
   }
+
+  processNodes(topLevelNodes, 0)
+  return rects // TODO: sort rects by levelCoord to be consistent with toRects?
 }
 
-function sortForwardSegs(forwardSegs: Seg[], eventOrderSpecs) {
-  let objs = forwardSegs.map(buildTimeGridSegCompareObj)
+/* TODO: for event-limit display
+interface SegEntryGroup {
+  spanStart: number
+  spanEnd: number
+  entries: SegEntry[]
+}
+
+// returns in no specific order
+function groupIntersectingEntries(entries: SegEntry[]): SegEntryGroup[] {
+  let groups: SegEntryGroup[] = []
+
+  for (let entry of entries) {
+    let filteredMerges: SegEntryGroup[] = []
+    let hungryMerge: SegEntryGroup = { // the merge that will eat what is collides with
+      spanStart: entry.spanStart,
+      spanEnd: entry.spanEnd,
+      entries: [entry]
+    }
 
-  let specs = [
-    // put higher-pressure first
-    { field: 'forwardPressure', order: -1 },
-    // put segments that are closer to initial edge first (and favor ones with no coords yet)
-    { field: 'backwardCoord', order: 1 },
-  ].concat(eventOrderSpecs)
+    for (let merge of groups) {
+      if (merge.spanStart < hungryMerge.spanEnd && merge.spanEnd > hungryMerge.spanStart) { // collides?
+        hungryMerge = {
+          spanStart: Math.min(merge.spanStart, hungryMerge.spanStart),
+          spanEnd: Math.max(merge.spanEnd, hungryMerge.spanEnd),
+          entries: merge.entries.concat(hungryMerge.entries)
+        }
+      } else {
+        filteredMerges.push(merge)
+      }
+    }
 
-  objs.sort((obj0, obj1) => compareByFieldSpecs(obj0, obj1, specs))
+    filteredMerges.push(hungryMerge)
+    groups = filteredMerges
+  }
 
-  return objs.map((c) => c._seg)
+  return groups
 }
+*/
 
-function buildTimeGridSegCompareObj(seg: Seg): any {
-  let obj = buildSegCompareObj(seg) as any
+// TODO: move to general util
 
-  obj.forwardPressure = seg.forwardPressure
-  obj.backwardCoord = seg.backwardCoord
+function cacheable<Args extends any[], Res>(
+  keyFunc: (...args: Args) => string,
+  workFunc: (...args: Args) => Res
+): ((...args: Args) => Res) {
+  const cache: { [key: string]: Res } = {}
 
-  return obj
+  return (...args: Args) => {
+    let key = keyFunc(...args)
+    return (key in cache)
+      ? cache[key]
+      : (cache[key] = workFunc(...args))
+  }
 }