@babylimon/react-calendar-timeline
Version:
react calendar timeline
811 lines (740 loc) • 22.3 kB
JavaScript
import moment from 'moment'
import { _get, arraysEqual } from './generic'
import memoize from 'memoize-one';
/**
* Calculate the ms / pixel ratio of the timeline state
* @param {number} canvasTimeStart
* @param {number} canvasTimeEnd
* @param {number} canvasWidth
* @returns {number}
*/
export function coordinateToTimeRatio(
canvasTimeStart,
canvasTimeEnd,
canvasWidth
) {
return (canvasTimeEnd - canvasTimeStart) / canvasWidth
}
/**
* For a given time, calculate the pixel position given timeline state
* (timeline width in px, canvas time range)
* @param {number} canvasTimeStart
* @param {number} canvasTimeEnd
* @param {number} canvasWidth
* @param {number} time
* @returns {number}
*/
export function calculateXPositionForTime(
canvasTimeStart,
canvasTimeEnd,
canvasWidth,
time
) {
const widthToZoomRatio = canvasWidth / (canvasTimeEnd - canvasTimeStart)
const timeOffset = time - canvasTimeStart
return timeOffset * widthToZoomRatio
}
/**
* For a given x position (leftOffset) in pixels, calculate time based on
* timeline state (timeline width in px, canvas time range)
* @param {number} canvasTimeStart
* @param {number} canvasTimeEnd
* @param {number} canvasWidth
* @param {number} leftOffset
* @returns {number}
*/
export function calculateTimeForXPosition(
canvasTimeStart,
canvasTimeEnd,
canvasWidth,
leftOffset
) {
const timeToPxRatio = (canvasTimeEnd - canvasTimeStart) / canvasWidth
const timeFromCanvasTimeStart = timeToPxRatio * leftOffset
return timeFromCanvasTimeStart + canvasTimeStart
}
export function iterateTimes(start, end, unit, timeSteps, callback) {
let time = moment(start).startOf(unit)
if (timeSteps[unit] && timeSteps[unit] > 1) {
let value = time.get(unit)
time.set(unit, value - value % timeSteps[unit])
}
while (time.valueOf() < end) {
let nextTime = moment(time).add(timeSteps[unit] || 1, `${unit}s`)
callback(time, nextTime)
time = nextTime
}
}
// this function is VERY HOT as its used in Timeline.js render function
// TODO: check if there are performance implications here
// when "weeks" feature is implemented, this function will be modified heavily
/** determine the current rendered time unit based on timeline time span
*
* zoom: (in milliseconds) difference between time start and time end of timeline canvas
* width: (in pixels) pixel width of timeline canvas
* timeSteps: map of timeDividers with number to indicate step of each divider
*/
// the smallest cell we want to render is 17px
// this can be manipulated to make the breakpoints change more/less
// i.e. on zoom how often do we switch to the next unit of time
// i think this is the distance between cell lines
export const minCellWidth = 17
export function getMinUnit(zoom, width, timeSteps) {
// for supporting weeks, its important to remember that each of these
// units has a natural progression to the other. i.e. a year is 12 months
// a month is 24 days, a day is 24 hours.
// with weeks this isnt the case so weeks needs to be handled specially
let timeDividers = {
second: 1000,
minute: 60,
hour: 60,
day: 24,
month: 30,
year: 12
}
let minUnit = 'year'
// this timespan is in ms initially
let nextTimeSpanInUnitContext = zoom
Object.keys(timeDividers).some(unit => {
// converts previous time span to current unit
// (e.g. milliseconds to seconds, seconds to minutes, etc)
nextTimeSpanInUnitContext = nextTimeSpanInUnitContext / timeDividers[unit]
// timeSteps is "
// With what step to display different units. E.g. 15 for minute means only minutes 0, 15, 30 and 45 will be shown."
// how many cells would be rendered given this time span, for this unit?
// e.g. for time span of 60 minutes, and time step of 1, we would render 60 cells
const cellsToBeRenderedForCurrentUnit =
nextTimeSpanInUnitContext / timeSteps[unit]
// what is happening here? why 3 if time steps are greater than 1??
const cellWidthToUse =
timeSteps[unit] && timeSteps[unit] > 1 ? 3 * minCellWidth : minCellWidth
// for the minWidth of a cell, how many cells would be rendered given
// the current pixel width
// i.e. f
const minimumCellsToRenderUnit = width / cellWidthToUse
if (cellsToBeRenderedForCurrentUnit < minimumCellsToRenderUnit) {
// for the current zoom, the number of cells we'd need to render all parts of this unit
// is less than the minimum number of cells needed at minimum cell width
minUnit = unit
return true
}
})
return minUnit
}
export function getNextUnit(unit) {
let nextUnits = {
second: 'minute',
minute: 'hour',
hour: 'day',
day: 'month',
month: 'year',
year: 'year'
}
if (!nextUnits[unit]) {
throw new Error(`unit ${unit} in not acceptable`)
}
return nextUnits[unit]
}
/**
* get the new start and new end time of item that is being
* dragged or resized
* @param {*} itemTimeStart original item time in milliseconds
* @param {*} itemTimeEnd original item time in milliseconds
* @param {*} dragTime new start time if item is dragged in milliseconds
* @param {*} isDragging is item being dragged
* @param {*} isResizing is item being resized
* @param {`right` or `left`} resizingEdge resize edge
* @param {*} resizeTime new resize time in milliseconds
*/
export function calculateInteractionNewTimes({
itemTimeStart,
itemTimeEnd,
dragTime,
isDragging,
isResizing,
resizingEdge,
resizeTime
}) {
const originalItemRange = itemTimeEnd - itemTimeStart
const itemStart =
isResizing && resizingEdge === 'left' ? resizeTime : itemTimeStart
const itemEnd =
isResizing && resizingEdge === 'right' ? resizeTime : itemTimeEnd
return [
isDragging ? dragTime : itemStart,
isDragging ? dragTime + originalItemRange : itemEnd
]
}
export function calculateDimensions({
itemTimeStart,
itemTimeEnd,
canvasTimeStart,
canvasTimeEnd,
canvasWidth
}) {
const itemTimeRange = itemTimeEnd - itemTimeStart
// restrict startTime and endTime to be bounded by canvasTimeStart and canvasTimeEnd
const effectiveStartTime = Math.max(itemTimeStart, canvasTimeStart)
const effectiveEndTime = Math.min(itemTimeEnd, canvasTimeEnd)
const left = calculateXPositionForTime(
canvasTimeStart,
canvasTimeEnd,
canvasWidth,
effectiveStartTime
)
const right = calculateXPositionForTime(
canvasTimeStart,
canvasTimeEnd,
canvasWidth,
effectiveEndTime
)
const itemWidth = right - left
const dimensions = {
left: left,
width: Math.max(itemWidth, 3),
collisionLeft: itemTimeStart,
collisionWidth: itemTimeRange
}
return dimensions
}
/**
* Get the order of groups based on their keys
* @param {*} groups array of groups
* @param {*} keys the keys object
* @returns Ordered hash of objects with their array index and group
*/
export function getGroupOrders(groups, keys) {
const { groupIdKey } = keys
let groupOrders = {}
for (let i = 0; i < groups.length; i++) {
groupOrders[_get(groups[i], groupIdKey)] = { index: i, group: groups[i] }
}
return groupOrders
}
export function getVisibleItems(items, canvasTimeStart, canvasTimeEnd, keys) {
const { itemTimeStartKey, itemTimeEndKey } = keys
return items.filter(item => {
return (
_get(item, itemTimeStartKey) <= canvasTimeEnd &&
_get(item, itemTimeEndKey) >= canvasTimeStart
)
})
}
const EPSILON = 0.001
export function collision(a, b, lineHeight, collisionPadding = EPSILON) {
// 2d collisions detection - https://developer.mozilla.org/en-US/docs/Games/Techniques/2D_collision_detection
var verticalMargin = 0
return (
a.collisionLeft + collisionPadding < b.collisionLeft + b.collisionWidth &&
a.collisionLeft + a.collisionWidth - collisionPadding > b.collisionLeft &&
a.top - verticalMargin + collisionPadding < b.top + b.height &&
a.top + a.height + verticalMargin - collisionPadding > b.top
)
}
/**
* Calculate the position of a given item for a group that
* is being stacked
*/
export function groupStack(
lineHeight,
item,
group,
groupHeight,
itemIndex
) {
// calculate non-overlapping positions
let curHeight = groupHeight
let verticalMargin = (lineHeight - item.dimensions.height) / 2
if (item.dimensions.stack && item.dimensions.top === null) {
item.dimensions.top = verticalMargin
curHeight = Math.max(curHeight, lineHeight)
do {
var collidingItem = null
//Items are placed from i=0 onwards, only check items with index < i
for (var j = itemIndex - 1, jj = 0; j >= jj; j--) {
var other = group[j]
if (
other.dimensions.top !== null &&
other.dimensions.stack &&
collision(item.dimensions, other.dimensions, lineHeight)
) {
collidingItem = other
break
} else {
// console.log('dont test', other.top !== null, other !== item, other.stack);
}
}
if (collidingItem != null) {
// There is a collision. Reposition the items above the colliding element
item.dimensions.top = collidingItem.dimensions.top + lineHeight
curHeight = Math.max(
curHeight,
item.dimensions.top + item.dimensions.height + verticalMargin
)
}
} while (collidingItem)
}
return {
groupHeight: curHeight,
verticalMargin,
itemTop: item.dimensions.top
}
}
// Calculate the position of this item for a group that is not being stacked
export function groupNoStack(lineHeight, item, groupHeight) {
let verticalMargin = (lineHeight - item.dimensions.height) / 2
if (item.dimensions.top === null) {
item.dimensions.top = verticalMargin
groupHeight = Math.max(groupHeight, lineHeight)
}
return { groupHeight, verticalMargin: 0, itemTop: item.dimensions.top }
}
function sum(arr = []) {
return arr.reduce((acc, i) => acc + i, 0)
}
/**
*
* @param {*} itemsDimensions
* @param {*} isGroupStacked
* @param {*} lineHeight
*/
export function stackGroup(itemsDimensions, isGroupStacked, lineHeight) {
var groupHeight = 0
var verticalMargin = 0
// Find positions for each item in group
for (let itemIndex = 0; itemIndex < itemsDimensions.length; itemIndex++) {
let r = {}
if (isGroupStacked) {
r = groupStack(
lineHeight,
itemsDimensions[itemIndex],
itemsDimensions,
groupHeight,
itemIndex
)
} else {
r = groupNoStack(lineHeight, itemsDimensions[itemIndex], groupHeight)
}
groupHeight = r.groupHeight
verticalMargin = r.verticalMargin
}
return { groupHeight: groupHeight || lineHeight, verticalMargin }
}
/**
* Stack the items that will be visible
* within the canvas area
* @param {item[]} items
* @param {group[]} groups
* @param {number} canvasWidth
* @param {number} canvasTimeStart
* @param {number} canvasTimeEnd
* @param {*} keys
* @param {number} lineHeight
* @param {number} itemHeightRatio
* @param {boolean} stackItems
* @param {*} draggingItem
* @param {*} resizingItem
* @param {number} dragTime
* @param {left or right} resizingEdge
* @param {number} resizeTime
* @param {number} newGroupId
*/
export function stackTimelineItems(
items,
groups,
canvasWidth,
canvasTimeStart,
canvasTimeEnd,
keys,
lineHeight,
itemHeightRatio,
stackItems,
draggingItem,
resizingItem,
dragTime,
resizingEdge,
resizeTime,
newGroupId
) {
const itemsWithInteractions = items.map(item =>
getItemWithInteractions({
item,
keys,
draggingItem,
resizingItem,
dragTime,
resizingEdge,
resizeTime,
newGroupId
})
)
const visibleItemsWithInteraction = getVisibleItems(
itemsWithInteractions,
canvasTimeStart,
canvasTimeEnd,
keys
)
// if there are no groups return an empty array of dimensions
if (groups.length === 0) {
return {
groupsWithItemsDimensions: {},
height: 0,
groupHeights: [],
groupTops: []
}
}
const groupsWithItems = getOrderedGroupsWithItems(
groups,
visibleItemsWithInteraction,
keys
)
const groupsWithItemsDimensions = getGroupsWithItemDimensions(
groupsWithItems,
keys,
lineHeight,
itemHeightRatio,
stackItems,
canvasTimeStart,
canvasTimeEnd,
canvasWidth,
groups,
)
const groupHeights = groups.map(group => {
const groupKey = _get(group, keys.groupIdKey)
const groupsWithItemDimensions = groupsWithItemsDimensions[groupKey]
return groupsWithItemDimensions.height
})
const groupTops = groupHeights.reduce(
(acc, height, index) => {
//skip last calculation because we already have 0 as first item in acc
if(groupHeights.length -1 === index) return acc
const lastIndex = acc.length - 1
const lastTop = acc[lastIndex]
acc.push(lastTop + height)
return acc
},
[0]
)
const height = groupHeights.reduce((acc, height) => acc + height, 0)
return { groupsWithItemsDimensions, height, groupHeights, groupTops, itemsWithInteractions }
}
/**
* get canvas width from visible width
* @param {*} width
* @param {*} buffer
*/
export function getCanvasWidth(width, buffer = 3) {
return width * buffer
}
/**
* get item's position, dimensions and collisions
* @param {*} item
* @param {*} keys
* @param {*} canvasTimeStart
* @param {*} canvasTimeEnd
* @param {*} canvasWidth
* @param {*} lineHeight
* @param {*} itemHeightRatio
*/
export function getItemDimensions({
item,
keys,
canvasTimeStart,
canvasTimeEnd,
canvasWidth,
lineHeight,
itemHeightRatio
}) {
const itemId = _get(item, keys.itemIdKey)
let dimension = calculateDimensions({
itemTimeStart: _get(item, keys.itemTimeStartKey),
itemTimeEnd: _get(item, keys.itemTimeEndKey),
canvasTimeStart,
canvasTimeEnd,
canvasWidth
})
if (dimension) {
dimension.top = null
dimension.stack = !item.isOverlay
dimension.height = lineHeight * itemHeightRatio
return {
id: itemId,
dimensions: dimension
}
}
}
/**
* get new item with changed `itemTimeStart` , `itemTimeEnd` and `itemGroupKey` according to user interaction
* user interaction is dragging an item and resize left and right
* @param {*} item
* @param {*} keys
* @param {*} draggingItem
* @param {*} resizingItem
* @param {*} dragTime
* @param {*} resizingEdge
* @param {*} resizeTime
* @param {*} groups
* @param {*} newGroupId
*/
export function getItemWithInteractions({
item,
keys,
draggingItem,
resizingItem,
dragTime,
resizingEdge,
resizeTime,
newGroupId
}) {
//TODO: remove from here. This shouldn't be this function's responsibility
if (!resizingItem && !draggingItem) return item
const itemId = _get(item, keys.itemIdKey)
const isDragging = itemId === draggingItem
const isResizing = itemId === resizingItem
//return item if is not being dragged or resized
if(!isResizing && !isDragging) return item
const [itemTimeStart, itemTimeEnd] = calculateInteractionNewTimes({
itemTimeStart: _get(item, keys.itemTimeStartKey),
itemTimeEnd: _get(item, keys.itemTimeEndKey),
isDragging,
isResizing,
dragTime,
resizingEdge,
resizeTime
})
const newItem = {
...item,
[keys.itemTimeStartKey]: itemTimeStart,
[keys.itemTimeEndKey]: itemTimeEnd,
[keys.itemGroupKey]: isDragging
? newGroupId
: _get(item, keys.itemGroupKey)
}
return newItem
}
/**
* get canvas start and end time from visible start and end time
* @param {number} visibleTimeStart
* @param {number} visibleTimeEnd
*/
export function getCanvasBoundariesFromVisibleTime(
visibleTimeStart,
visibleTimeEnd
) {
const zoom = visibleTimeEnd - visibleTimeStart
const canvasTimeStart = visibleTimeStart - (visibleTimeEnd - visibleTimeStart)
const canvasTimeEnd = canvasTimeStart + zoom * 3
return [canvasTimeStart, canvasTimeEnd]
}
/**
* Get the the canvas area for a given visible time
* Will shift the start/end of the canvas if the visible time
* does not fit within the existing
* @param {number} visibleTimeStart
* @param {number} visibleTimeEnd
* @param {boolean} forceUpdateDimensions
* @param {*} items
* @param {*} groups
* @param {*} props
* @param {*} state
*/
export function calculateScrollCanvas(
visibleTimeStart,
visibleTimeEnd,
forceUpdateDimensions,
items,
groups,
props,
state
) {
const oldCanvasTimeStart = state.canvasTimeStart
const oldZoom = state.visibleTimeEnd - state.visibleTimeStart
const newZoom = visibleTimeEnd - visibleTimeStart
const newState = { visibleTimeStart, visibleTimeEnd }
// Check if the current canvas covers the new times
const canKeepCanvas =
newZoom === oldZoom &&
visibleTimeStart >= oldCanvasTimeStart + oldZoom * 0.5 &&
visibleTimeStart <= oldCanvasTimeStart + oldZoom * 1.5 &&
visibleTimeEnd >= oldCanvasTimeStart + oldZoom * 1.5 &&
visibleTimeEnd <= oldCanvasTimeStart + oldZoom * 2.5
if (!canKeepCanvas || forceUpdateDimensions) {
const [canvasTimeStart, canvasTimeEnd] = getCanvasBoundariesFromVisibleTime(
visibleTimeStart,
visibleTimeEnd
)
newState.canvasTimeStart = canvasTimeStart
newState.canvasTimeEnd = canvasTimeEnd
const mergedState = {
...state,
...newState
}
const canvasWidth = getCanvasWidth(mergedState.width)
// The canvas cannot be kept, so calculate the new items position
Object.assign(
newState,
stackTimelineItems(
items,
groups,
canvasWidth,
mergedState.canvasTimeStart,
mergedState.canvasTimeEnd,
props.keys,
props.lineHeight,
props.itemHeightRatio,
props.stackItems,
mergedState.draggingItem,
mergedState.resizingItem,
mergedState.dragTime,
mergedState.resizingEdge,
mergedState.resizeTime,
mergedState.newGroupId
)
)
}
return newState
}
/**
* get item dimensions of a group
* @param {*} groupWithItems
* @param {*} keys
* @param {*} canvasTimeStart
* @param {*} canvasTimeEnd
* @param {*} canvasWidth
* @param {*} lineHeight
* @param {*} itemHeightRatio
* @param {*} stackItems
*/
export function getGroupWithItemDimensions(
groupWithItems,
keys,
canvasTimeStart,
canvasTimeEnd,
canvasWidth,
lineHeight,
itemHeightRatio,
stackItems
) {
const itemDimensions = groupWithItems.items.map(item => {
return getItemDimensions({
item,
keys,
canvasTimeStart,
canvasTimeEnd,
canvasWidth,
lineHeight,
itemHeightRatio
})
})
//If group height property is specified, use that instead of manual calculation
let groupHeightProp = null;
if (groupWithItems && groupWithItems.group) groupHeightProp = groupWithItems.group.height
const { groupHeight } = groupHeightProp ?
{ groupHeight: groupHeightProp } :
stackGroup(itemDimensions, stackItems, lineHeight)
return {
...groupWithItems,
itemDimensions: itemDimensions,
height: groupHeight
}
}
/**
* group timeline items by key
* returns a key/array pair object
* @param {*} items
* @param {*} key
*/
export function groupItemsByKey(items, key) {
return items.reduce((acc, item) => {
const itemKey = _get(item, key)
if (acc[itemKey]) {
acc[itemKey].push(item)
} else {
acc[itemKey] = [item]
}
return acc
}, {})
}
export function getOrderedGroupsWithItems(groups, items, keys) {
const groupOrders = getGroupOrders(groups, keys)
const groupsWithItems = {}
const groupKeys = Object.keys(groupOrders)
const groupedItems = groupItemsByKey(items, keys.itemGroupKey)
// Initialize with result object for each group
for (let i = 0; i < groupKeys.length; i++) {
const groupOrder = groupOrders[groupKeys[i]]
groupsWithItems[groupKeys[i]] = {
index: groupOrder.index,
group: groupOrder.group,
items: groupedItems[_get(groupOrder.group, keys.groupIdKey)] || []
}
}
return groupsWithItems
}
/**
* shallow compare ordered groups with items
* if index or group changed reference compare then not equal
* if new/old group's items changed array shallow equality then not equal
* @param {*} newGroup
* @param {*} oldGroup
*/
export function shallowIsEqualOrderedGroup(newGroup, oldGroup){
if(newGroup.group !== oldGroup.group) return false
if(newGroup.index !== oldGroup.index) return false
return arraysEqual(newGroup.items, oldGroup.items)
}
/**
* compare getGroupWithItemDimensions params. All params are compared via reference equality
* only groups are checked via a custom shallow equality
* @param {*} newArgs
* @param {*} oldArgs
*/
export function isEqualItemWithDimensions(newArgs, oldArgs){
const [newGroup, ...newRest] = newArgs;
const [oldGroup, ...oldRest] = oldArgs;
//shallow equality
if(!arraysEqual(newRest, oldRest)) return false;
return shallowIsEqualOrderedGroup(newGroup, oldGroup)
}
/**
* returns a cache in the form of dictionary ([groupId]: cachedMethod) for calculating getGroupWithItemDimensions
* the cache is cleared if groups or keys changed in reference
* @param {*} groups
* @param {*} keys
*/
export const getGroupsCache = memoize((groups, keys, method)=>{
return groups.reduce((acc, group) => {
const id = _get(group, keys.groupIdKey);
acc[id] = memoize(method, isEqualItemWithDimensions)
return acc
}, {})
})
export function getGroupsWithItemDimensions(
groupsWithItems,
keys,
lineHeight,
itemHeightRatio,
stackItems,
canvasTimeStart,
canvasTimeEnd,
canvasWidth,
groups,
) {
const cache = getGroupsCache(groups, keys, getGroupWithItemDimensions)
const groupKeys = Object.keys(groupsWithItems)
return groupKeys.reduce((acc, groupKey) => {
const group = groupsWithItems[groupKey]
const cachedGetGroupWithItemDimensions = cache[groupKey];
acc[groupKey] = cachedGetGroupWithItemDimensions(
group,
keys,
canvasTimeStart,
canvasTimeEnd,
canvasWidth,
lineHeight,
itemHeightRatio,
stackItems
)
return acc
}, {})
}