-
Notifications
You must be signed in to change notification settings - Fork 96
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Feature: enable stacking events (#255)
* Implement stacking events * Add resolveMethod: ignore Rename path to track * Fix minor bugs Reset stack in lane when evts arent overlappd moment function expects moment object * Add overlap docs * Refactor 'track' variable by 'overlap' * [stack-vs-lane] Simplify event position calculation * Refactor intp pipeline folder Move box, position and overlap files * Refactor into types file * Fill TS types and fill missing prop-types
Showing
17 changed files
with
511 additions
and
238 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,120 @@ | ||
# Overlap handling | ||
|
||
The library handles the case when two or more events overlap in time. | ||
|
||
## Simple usage | ||
|
||
For each event provide `resolveOverlap: <method>`, for example: | ||
|
||
|
||
| Lane (default) | Stack | Ignore | | ||
| :----------------------------------------: | :------------------------------------------: | :--------------------------------------------: | | ||
| ![lane](../images/overlap/simple-lane.png) | ![stack](../images/overlap/simple-stack.png) | ![ignore](../images/overlap/simple-ignore.png) | | ||
|
||
|
||
```js | ||
const method = 'lane' | 'stack' | 'ignore' | ||
const awesomeEvents = [ | ||
{ | ||
id: 1, | ||
description: 'Event 1', | ||
startDate: new Date(2022, 6, 21, 12, 0, 0), | ||
endDate: new Date(2022, 6, 21, 16, 30, 0), | ||
color: 'lightblue', | ||
resolveOverlap: method, | ||
}, | ||
{ | ||
id: 2, | ||
description: 'Event 2', | ||
startDate: new Date(2022, 6, 21, 13, 0, 0), | ||
endDate: new Date(2022, 6, 21, 14, 30, 0), | ||
color: 'green', | ||
resolveOverlap: method, | ||
}, | ||
{ | ||
id: 3, | ||
description: 'Event 3', | ||
startDate: new Date(2022, 6, 21, 14, 0, 0), | ||
endDate: new Date(2022, 6, 21, 15, 15, 0), | ||
color: 'pink', | ||
resolveOverlap: method, | ||
}, | ||
] | ||
``` | ||
|
||
## Advanced usage | ||
|
||
You can mix different methods and use `stackKey` for the stack method to better suit your needs. | ||
For example: | ||
|
||
![mix](../images/overlap/advanced-mix.png) | ||
|
||
```js | ||
const mixedMethods = [ | ||
// e.g. mix 'ignore' with 'lane' (you could also mix with 'stack') | ||
{ | ||
id: 1, | ||
startDate: new Date(2022, 6, 21, 12, 0, 0), | ||
endDate: new Date(2022, 6, 21, 16, 30, 0), | ||
color: 'blanchedalmond', | ||
description: 'Event 1', | ||
resolveOverlap: 'ignore', | ||
}, | ||
{ | ||
id: 2, | ||
startDate: new Date(2022, 6, 21, 13, 0, 0), | ||
endDate: new Date(2022, 6, 21, 14, 30, 0), | ||
color: 'forestgreen', | ||
description: 'Event 2', | ||
resolveOverlap: 'lane', | ||
}, | ||
{ | ||
id: 3, | ||
startDate: new Date(2022, 6, 21, 14, 0, 0), | ||
endDate: new Date(2022, 6, 21, 15, 15, 0), | ||
color: 'lightgreen', | ||
description: 'Event 3', | ||
resolveOverlap: 'lane', | ||
}, | ||
] | ||
|
||
const multipleStacks = [ | ||
// e.g. stack only certain events together by providing a 'stackKey' | ||
{ | ||
id: 4, | ||
startDate: new Date(2022, 6, 23, 12, 0, 0), | ||
endDate: new Date(2022, 6, 23, 16, 30, 0), | ||
color: 'dodgerblue', | ||
description: 'EvtA 1', | ||
resolveOverlap: 'stack', | ||
stackKey: 'type-A', | ||
}, | ||
{ | ||
id: 5, | ||
startDate: new Date(2022, 6, 23, 14, 0, 0), | ||
endDate: new Date(2022, 6, 23, 18, 15, 0), | ||
color: 'lightblue', | ||
description: 'EvtA 2', | ||
resolveOverlap: 'stack', | ||
stackKey: 'type-A', | ||
}, | ||
{ | ||
id: 6, | ||
startDate: new Date(2022, 6, 23, 14, 30, 0), | ||
endDate: new Date(2022, 6, 23, 16, 30, 0), | ||
color: 'gold', | ||
description: 'EvtB 1', | ||
resolveOverlap: 'stack', | ||
stackKey: 'type-B', | ||
}, | ||
{ | ||
id: 7, | ||
startDate: new Date(2022, 6, 23, 16, 0, 0), | ||
endDate: new Date(2022, 6, 23, 18, 30, 0), | ||
color: 'orange', | ||
description: 'EvtB 2', | ||
resolveOverlap: 'stack', | ||
stackKey: 'type-B', | ||
}, | ||
] | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,49 @@ | ||
import moment from 'moment'; | ||
import { DATE_STR_FORMAT } from '../utils/dates'; | ||
|
||
const bucketEventsByDate = (events) => { | ||
// Stores the events hashed by their date | ||
// For example: { "2020-02-03": [event1, event2, ...] } | ||
// If an event spans through multiple days, adds the event multiple times | ||
const sortedEvents = {}; | ||
events.forEach((event) => { | ||
const startDate = moment(event.startDate); | ||
const endDate = moment(event.endDate); | ||
|
||
for ( | ||
let date = moment(startDate); | ||
date.isSameOrBefore(endDate, 'days'); | ||
date.add(1, 'days') | ||
) { | ||
// Calculate actual start and end dates | ||
const startOfDay = moment(date).startOf('day'); | ||
const endOfDay = moment(date).endOf('day'); | ||
|
||
// The event box is limited to the start and end of the day | ||
const boxStartDate = moment.max(startDate, startOfDay).toDate(); | ||
const boxEndDate = moment.min(endDate, endOfDay).toDate(); | ||
|
||
// Add to object | ||
const dateStr = date.format(DATE_STR_FORMAT); | ||
if (!sortedEvents[dateStr]) { | ||
sortedEvents[dateStr] = []; | ||
} | ||
sortedEvents[dateStr].push({ | ||
ref: event, | ||
box: { | ||
startDate: boxStartDate, | ||
endDate: boxEndDate, | ||
}, | ||
}); | ||
} | ||
}); | ||
// For each day, sort the events by the minute (in-place) | ||
Object.keys(sortedEvents).forEach((date) => { | ||
sortedEvents[date].sort((a, b) => { | ||
return moment(a.box.startDate).diff(b.box.startDate, 'minutes'); | ||
}); | ||
}); | ||
return sortedEvents; | ||
}; | ||
|
||
export default bucketEventsByDate; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,185 @@ | ||
/* eslint-disable max-classes-per-file */ | ||
import moment from 'moment'; | ||
import { OVERLAP_METHOD } from '../utils/types'; | ||
|
||
const ALLOW_OVERLAP_SECONDS = 2; | ||
|
||
const areEventsOverlapped = (event1EndDate, event2StartDate) => { | ||
if (!event1EndDate || !event2StartDate) return false; | ||
|
||
const endDate = moment(event1EndDate); | ||
endDate.subtract(ALLOW_OVERLAP_SECONDS, 'seconds'); | ||
return endDate.isSameOrAfter(event2StartDate); | ||
}; | ||
|
||
class Lane { | ||
constructor() { | ||
this.event2StackPosition = {}; | ||
this.latestDate = null; | ||
|
||
this.resetStack(); | ||
} | ||
|
||
resetStack = () => { | ||
this.currentStackLength = 0; | ||
this.stackKey = null; | ||
}; | ||
|
||
addToStack = (event, eventIndex) => { | ||
this.latestDate = | ||
this.latestDate == null | ||
? moment(event.endDate) | ||
: moment.max(moment(event.endDate), this.latestDate); | ||
this.stackKey = event.stackKey || null; | ||
this.event2StackPosition[eventIndex] = this.currentStackLength; | ||
this.currentStackLength += 1; | ||
}; | ||
|
||
addEvent = (event, eventIndex) => { | ||
if (!areEventsOverlapped(this.latestDate, event.startDate)) { | ||
this.resetStack(); | ||
} | ||
this.addToStack(event, eventIndex); | ||
}; | ||
} | ||
|
||
const IGNORED_EVENTS_META = { | ||
lane: 0, | ||
nLanes: 1, | ||
stackPosition: 0, | ||
}; | ||
|
||
class OverlappedEventsHandler { | ||
constructor() { | ||
this.lanes = []; | ||
this.event2LaneIndex = {}; | ||
this.ignoredEvents = {}; | ||
} | ||
|
||
saveEventToLane = (event, eventIndex, laneIndex) => { | ||
this.lanes[laneIndex].addEvent(event, eventIndex); | ||
|
||
this.event2LaneIndex[eventIndex] = laneIndex; | ||
}; | ||
|
||
findFirstLaneNotOverlapping = (startDate) => | ||
this.lanes.findIndex( | ||
(lane) => !areEventsOverlapped(lane.latestDate, startDate), | ||
); | ||
|
||
addToNextAvailableLane = (event, eventIndex) => { | ||
let laneIndex = this.findFirstLaneNotOverlapping(event.startDate); | ||
if (laneIndex === -1) { | ||
this.lanes.push(new Lane()); | ||
laneIndex = this.lanes.length - 1; | ||
} | ||
this.saveEventToLane(event, eventIndex, laneIndex); | ||
}; | ||
|
||
findLaneWithOverlapAndKey = (startDate, targetKey = null) => | ||
this.lanes.findIndex( | ||
(lane) => | ||
(targetKey == null || targetKey === lane.stackKey) && | ||
areEventsOverlapped(lane.latestDate, startDate), | ||
); | ||
|
||
addToNextMatchingStack = (event, eventIndex) => { | ||
const laneIndex = this.findLaneWithOverlapAndKey( | ||
event.startDate, | ||
event.stackKey, | ||
); | ||
if (laneIndex !== -1) { | ||
this.saveEventToLane(event, eventIndex, laneIndex); | ||
} else { | ||
this.addToNextAvailableLane(event, eventIndex); | ||
} | ||
}; | ||
|
||
addAsIgnored = (eventIndex) => { | ||
this.ignoredEvents[eventIndex] = true; | ||
}; | ||
|
||
static buildFromOverlappedEvents = (events) => { | ||
const layout = new OverlappedEventsHandler(); | ||
|
||
(events || []).forEach(({ ref: event }, eventIndex) => { | ||
switch (event.resolveOverlap) { | ||
case OVERLAP_METHOD.STACK: | ||
layout.addToNextMatchingStack(event, eventIndex); | ||
break; | ||
case OVERLAP_METHOD.IGNORE: | ||
layout.addAsIgnored(eventIndex); | ||
break; | ||
case OVERLAP_METHOD.LANE: | ||
default: | ||
layout.addToNextAvailableLane(event, eventIndex); | ||
break; | ||
} | ||
}); | ||
return layout; | ||
}; | ||
|
||
getEventOverlapMeta = (eventIndex) => { | ||
if (this.ignoredEvents[eventIndex]) { | ||
return IGNORED_EVENTS_META; | ||
} | ||
const laneIndex = this.event2LaneIndex[eventIndex]; | ||
if (laneIndex == null || laneIndex > this.lanes.length) { | ||
// internal error | ||
return {}; | ||
} | ||
const lane = this.lanes[laneIndex]; | ||
return { | ||
lane: laneIndex, | ||
nLanes: this.lanes.length, | ||
stackPosition: lane.event2StackPosition[eventIndex], | ||
}; | ||
}; | ||
} | ||
|
||
const addOverlappedToArray = (baseArr, overlappedArr) => { | ||
if (!overlappedArr) return; | ||
|
||
const nOverlapped = overlappedArr.length; | ||
if (nOverlapped === 0) { | ||
return; | ||
} | ||
if (nOverlapped === 1) { | ||
baseArr.push(overlappedArr[0]); | ||
return; | ||
} | ||
|
||
const layout = OverlappedEventsHandler.buildFromOverlappedEvents( | ||
overlappedArr, | ||
); | ||
|
||
overlappedArr.forEach(({ ref, box }, eventIndex) => { | ||
baseArr.push({ | ||
ref, | ||
box, | ||
overlap: layout.getEventOverlapMeta(eventIndex), | ||
}); | ||
}); | ||
}; | ||
|
||
const resolveEventOverlaps = (events) => { | ||
let overlappedSoFar = []; | ||
let lastDate = null; | ||
const resolvedEvents = events.reduce((accumulated, eventWithMeta) => { | ||
const { box } = eventWithMeta; | ||
if (!lastDate || areEventsOverlapped(lastDate, box.startDate)) { | ||
overlappedSoFar.push(eventWithMeta); | ||
const endDate = moment(box.endDate); | ||
lastDate = lastDate ? moment.max(endDate, lastDate) : endDate; | ||
} else { | ||
addOverlappedToArray(accumulated, overlappedSoFar); | ||
overlappedSoFar = [eventWithMeta]; | ||
lastDate = moment(box.endDate); | ||
} | ||
return accumulated; | ||
}, []); | ||
addOverlappedToArray(resolvedEvents, overlappedSoFar); | ||
return resolvedEvents; | ||
}; | ||
|
||
export default resolveEventOverlaps; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,51 @@ | ||
import { minutesInDay } from '../utils/dates'; | ||
import { minutesInDayToTop, minutesToHeight } from '../utils/dimensions'; | ||
|
||
const EVENT_HORIZONTAL_PADDING = 8 / 100; | ||
const STACK_OFFSET_FRACTION = 15 / 100; | ||
const MIN_ITEM_WIDTH = 4; // pixels | ||
|
||
const computeWidthByLane = (overlap, dayWidth) => | ||
dayWidth / (overlap.nLanes || 1); | ||
|
||
const computeHorizontalPadding = (overlap, dayWidth) => { | ||
const widthByLane = computeWidthByLane(overlap, dayWidth); | ||
const paddingByLane = EVENT_HORIZONTAL_PADDING / (overlap.nLanes || 1); | ||
return widthByLane * paddingByLane; | ||
}; | ||
|
||
const computeStackOffset = (overlap, dayWidth) => { | ||
const widthByLane = computeWidthByLane(overlap, dayWidth); | ||
return widthByLane * STACK_OFFSET_FRACTION * (overlap.stackPosition || 0); | ||
}; | ||
|
||
export const computeWidth = (overlap, dayWidth) => { | ||
const width = | ||
computeWidthByLane(overlap, dayWidth) - | ||
computeHorizontalPadding(overlap, dayWidth) - | ||
computeStackOffset(overlap, dayWidth); | ||
return Math.max(width, MIN_ITEM_WIDTH); | ||
}; | ||
|
||
const computeLaneOffset = (overlap, dayWidth) => | ||
computeWidthByLane(overlap, dayWidth) * (overlap.lane || 0); | ||
|
||
export const computeLeft = (overlap, dayWidth) => { | ||
const left = | ||
computeLaneOffset(overlap, dayWidth) + | ||
computeStackOffset(overlap, dayWidth); | ||
return Math.min(left, dayWidth); | ||
}; | ||
|
||
export const computeHeight = (box, verticalResolution) => | ||
minutesToHeight( | ||
minutesInDay(box.endDate) - minutesInDay(box.startDate), | ||
verticalResolution, | ||
); | ||
|
||
export const computeTop = (box, verticalResolution, beginAgendaAt) => | ||
minutesInDayToTop( | ||
minutesInDay(box.startDate), | ||
verticalResolution, | ||
beginAgendaAt, | ||
); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,50 @@ | ||
import PropTypes from 'prop-types'; | ||
|
||
export const EditEventConfigPropType = PropTypes.shape({ | ||
left: PropTypes.bool, | ||
top: PropTypes.bool, | ||
right: PropTypes.bool, | ||
bottom: PropTypes.bool, | ||
}); | ||
|
||
export const OVERLAP_METHOD = { | ||
STACK: 'stack', | ||
LANE: 'lane', | ||
IGNORE: 'ignore', | ||
}; | ||
|
||
export const ResolveOverlapPropType = PropTypes.oneOf( | ||
Object.values(OVERLAP_METHOD), | ||
); | ||
|
||
export const EventPropType = PropTypes.shape({ | ||
color: PropTypes.string, | ||
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, | ||
description: PropTypes.string, | ||
startDate: PropTypes.instanceOf(Date).isRequired, | ||
endDate: PropTypes.instanceOf(Date).isRequired, | ||
style: PropTypes.object, | ||
disableDrag: PropTypes.bool, | ||
disablePress: PropTypes.bool, | ||
disableLongPress: PropTypes.bool, | ||
resolveOverlap: ResolveOverlapPropType, | ||
stackKey: PropTypes.string, | ||
}); | ||
|
||
export const EventWithMetaPropType = PropTypes.shape({ | ||
ref: EventPropType.isRequired, | ||
box: PropTypes.shape({ | ||
startDate: PropTypes.instanceOf(Date).isRequired, | ||
endDate: PropTypes.instanceOf(Date).isRequired, | ||
}).isRequired, | ||
}); | ||
|
||
export const GridRowPropType = PropTypes.shape({ | ||
borderColor: PropTypes.string, | ||
borderTopWidth: PropTypes.number, | ||
}); | ||
|
||
export const GridColumnPropType = PropTypes.shape({ | ||
borderColor: PropTypes.string, | ||
borderLeftWidth: PropTypes.number, | ||
}); |