Skip to content

Commit

Permalink
Feature: enable stacking events (#255)
Browse files Browse the repository at this point in the history
* 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
pdpino authored Sep 17, 2022

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
1 parent 7c306c2 commit b9dbec5
Showing 17 changed files with 511 additions and 238 deletions.
20 changes: 13 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -172,13 +172,19 @@ See [CHANGELOG.md](docs/CHANGELOG.md) for details.
#### Special fields

There are some fields in the `EventItem` that provide extra customizations for each event.

| Extra `EventItem` fields | Type | Default | Description |
| ------------------------ | -------- | ------- | ---------------------------------------- |
| `style` | `Object` | `null` | Provide extra styling for the container. |
| `disableDrag` | `bool` | `false` | Disables drag-and-drop interaction. |
| `disablePress` | `bool` | `false` | Disables onPress interaction. |
| `disableLongPress` | `bool` | `false` | Disables onLongPress interaction. |
* Style per event
* Disable user interactions (e.g. drag, press)
* Event overlap handling [see more details](docs/overlaps.md).

| Extra `EventItem` fields | Type | Default | Description |
| ------------------------ | ----------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `style` | `Object` | `null` | Provide extra styling for the container. |
| `disableDrag` | `bool` | `false` | Disables drag-and-drop interaction. |
| `disablePress` | `bool` | `false` | Disables onPress interaction. |
| `disableLongPress` | `bool` | `false` | Disables onLongPress interaction. |
| **_Event overlaps_** |
| `resolveOverlap` | `'lane'` \| `'stack'` \| `'ignore'` | `'lane'` | Defines the method to resolve overlaps for that event. |
| `stackKey` | _String_ | `null` | Limit the events it can be stacked with. If is `null`, it can be stacked with any other event. Only useful if `resolveMethod = 'stack'` |


### Methods
120 changes: 120 additions & 0 deletions docs/overlaps.md
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',
},
]
```
4 changes: 2 additions & 2 deletions example/App.js
Original file line number Diff line number Diff line change
@@ -27,7 +27,7 @@ const sampleEvents = [

// This week
buildEvent(0, 2, 'blue'),
buildEvent(1, 3, 'red'),
buildEvent(1, 3, 'red', {resolveOverlap: 'lane'}),
buildEvent(-18, 4, 'green'),

// Next week
@@ -40,7 +40,7 @@ const sampleEvents = [
disablePress: true,
disableLongPress: true,
}),
buildEvent(24 * 7 + 6, 6, 'brown'),
buildEvent(24 * 7 + 6, 6, 'brown', {resolveOverlap: 'ignore'}),

// Two more weeks
buildEvent(48 * 7, 2, 'pink'),
3 changes: 3 additions & 0 deletions example/debug-utils.js
Original file line number Diff line number Diff line change
@@ -14,12 +14,15 @@ export const makeBuilder = () => {

return (start, duration, color, more = {}) => {
index += 1;
const stackKey = index % 2 === 0 ? 'A' : 'B';
return {
id: index,
description: `Event ${index}`,
startDate: generateDates(start),
endDate: generateDates(start + duration),
color,
stackKey: `evt-${stackKey}`,
resolveOverlap: 'stack',
...(more || {}),
};
};
Binary file added images/overlap/advanced-mix.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added images/overlap/simple-ignore.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added images/overlap/simple-lane.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added images/overlap/simple-stack.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
@@ -7,6 +7,8 @@ export interface WeekViewEvent extends Record<string, any> {
description: string;
startDate: Date;
endDate: Date;
resolveOverlap: 'stack' | 'lane' | 'ignore';
stackKey: string;
color: string;
}

23 changes: 3 additions & 20 deletions src/Event/Event.js
Original file line number Diff line number Diff line change
@@ -12,6 +12,7 @@ import Animated, {
useDerivedValue,
} from 'react-native-reanimated';
import styles, { circleStyles } from './Event.styles';
import { EventPropType, EditEventConfigPropType } from '../utils/types';

const DEFAULT_COLOR = 'red';
const UPDATE_EVENT_ANIMATION_DURATION = 150;
@@ -305,27 +306,8 @@ const Event = ({
);
};

export const EditEventConfigPropType = PropTypes.shape({
left: PropTypes.bool,
top: PropTypes.bool,
right: PropTypes.bool,
bottom: PropTypes.bool,
});

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,
});

Event.propTypes = {
event: eventPropType.isRequired,
event: EventPropType.isRequired,
top: PropTypes.number.isRequired,
left: PropTypes.number.isRequired,
height: PropTypes.number.isRequired,
@@ -337,6 +319,7 @@ Event.propTypes = {
onDrag: PropTypes.func,
onEdit: PropTypes.func,
editingEventId: PropTypes.number,
editEventConfig: EditEventConfigPropType,
};

export default React.memo(Event);
181 changes: 21 additions & 160 deletions src/Events/Events.js
Original file line number Diff line number Diff line change
@@ -9,146 +9,28 @@ import moment from 'moment';
import memoizeOne from 'memoize-one';

import NowLine from '../NowLine/NowLine';
import Event, { eventPropType } from '../Event/Event';
import Event from '../Event/Event';
import {
EventWithMetaPropType,
GridRowPropType,
GridColumnPropType,
} from '../utils/types';
import {
calculateDaysArray,
DATE_STR_FORMAT,
availableNumberOfDays,
minutesInDay,
} from '../utils/dates';
import {
minutesInDayToTop,
minutesToHeight,
topToSecondsInDay as topToSecondsInDayFromUtils,
} from '../utils/dimensions';
import { topToSecondsInDay as topToSecondsInDayFromUtils } from '../utils/dimensions';
import { ViewWithTouchable } from '../utils/gestures';

import styles from './Events.styles';

const EVENT_HORIZONTAL_PADDING = 8; // percentage
const MIN_ITEM_WIDTH = 4;
const ALLOW_OVERLAP_SECONDS = 2;

const padItemWidth = (width, paddingPercentage = EVENT_HORIZONTAL_PADDING) =>
paddingPercentage > 0
? width - Math.max(2, (width * paddingPercentage) / 100)
: width;

const computeWidth = (box, dayWidth) => {
const dividedWidth = dayWidth / (box.nLanes || 1);
const dividedPadding = EVENT_HORIZONTAL_PADDING / (box.nLanes || 1);
const width = Math.max(
padItemWidth(dividedWidth, dividedPadding),
MIN_ITEM_WIDTH,
);
return width;
};

const computeLeft = (box, dayWidth) => {
if (!box.lane || !box.nLanes) return 0;
const dividedWidth = dayWidth / box.nLanes;
return dividedWidth * box.lane;
};

const computeHeight = (box, verticalResolution) =>
minutesToHeight(
minutesInDay(box.endDate) - minutesInDay(box.startDate),
verticalResolution,
);

const computeTop = (box, verticalResolution, beginAgendaAt) =>
minutesInDayToTop(
minutesInDay(box.startDate),
verticalResolution,
beginAgendaAt,
);

const areEventsOverlapped = (event1EndDate, event2StartDate) => {
const endDate = moment(event1EndDate);
endDate.subtract(ALLOW_OVERLAP_SECONDS, 'seconds');
return endDate.isSameOrAfter(event2StartDate);
};

const addOverlappedToArray = (baseArr, overlappedArr) => {
// Given an array of overlapped events (with style), modifies their style to overlap them
// and adds them to a (base) array of events.
if (!overlappedArr) return;

const nOverlapped = overlappedArr.length;
if (nOverlapped === 0) {
return;
}
if (nOverlapped === 1) {
baseArr.push(overlappedArr[0]);
return;
}

let nLanes;
let indexToLane;
if (nOverlapped === 2) {
nLanes = nOverlapped;
indexToLane = (index) => index;
} else {
// Distribute events in multiple lanes
const maxLanes = nOverlapped;
const latestByLane = {};
const laneByEvent = {};
overlappedArr.forEach((event, index) => {
for (let lane = 0; lane < maxLanes; lane += 1) {
const lastEvtInLaneIndex = latestByLane[lane];
const lastEvtInLane =
(lastEvtInLaneIndex || lastEvtInLaneIndex === 0) &&
overlappedArr[lastEvtInLaneIndex];
if (
!lastEvtInLane ||
!areEventsOverlapped(lastEvtInLane.box.endDate, event.box.startDate)
) {
// Place in this lane
latestByLane[lane] = index;
laneByEvent[index] = lane;
break;
}
}
});

nLanes = Object.keys(latestByLane).length;
indexToLane = (index) => laneByEvent[index];
}

overlappedArr.forEach((eventWithStyle, index) => {
const { ref, box } = eventWithStyle;
baseArr.push({
ref,
box: {
...box,
nLanes,
lane: indexToLane(index),
},
});
});
};

const resolveEventOverlaps = (totalEvents) => {
return totalEvents.map((events) => {
let overlappedSoFar = []; // Store events overlapped until now
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;
});
};
import resolveEventOverlaps from '../pipeline/overlap';
import {
computeHeight,
computeWidth,
computeLeft,
computeTop,
} from '../pipeline/position';

const processEvents = (
eventsByDate,
@@ -160,12 +42,10 @@ const processEvents = (
// example: [[event1, event2], [event3, event4], [event5]], each child array
// is events for specific day in range
const dates = calculateDaysArray(initialDate, numberOfDays, rightToLeft);
const totalEvents = dates.map((date) => {
return dates.map((date) => {
const dateStr = date.format(DATE_STR_FORMAT);
return eventsByDate[dateStr] || [];
return resolveEventOverlaps(eventsByDate[dateStr] || []);
});

return resolveEventOverlaps(totalEvents);
};

const Lines = ({ initialDate, times, timeLabelHeight, gridRowStyle }) => {
@@ -346,15 +226,15 @@ class Events extends PureComponent {
/>
)}
{eventsInSection.map((item) => {
const { ref: event, box } = item;
const { ref: event, box, overlap = {} } = item;
return (
<Event
key={event.id}
event={event}
top={computeTop(box, verticalResolution, beginAgendaAt)}
left={computeLeft(box, dayWidth)}
height={computeHeight(box, verticalResolution)}
width={computeWidth(box, dayWidth)}
left={computeLeft(overlap, dayWidth)}
width={computeWidth(overlap, dayWidth)}
onPress={onEventPress && this.handlePressEvent}
onLongPress={onEventLongPress && this.handleLongPressEvent}
EventComponent={EventComponent}
@@ -374,29 +254,10 @@ class Events extends PureComponent {
}
}

export const GridRowPropType = PropTypes.shape({
borderColor: PropTypes.string,
borderTopWidth: PropTypes.number,
});

export const GridColumnPropType = PropTypes.shape({
borderColor: PropTypes.string,
borderLeftWidth: PropTypes.number,
});

Events.propTypes = {
numberOfDays: PropTypes.oneOf(availableNumberOfDays).isRequired,
eventsByDate: PropTypes.objectOf(
PropTypes.arrayOf(
PropTypes.shape({
ref: eventPropType.isRequired,
box: PropTypes.shape({
startDate: PropTypes.instanceOf(Date).isRequired,
endDate: PropTypes.instanceOf(Date).isRequired,
}),
}),
),
).isRequired,
eventsByDate: PropTypes.objectOf(PropTypes.arrayOf(EventWithMetaPropType))
.isRequired,
initialDate: PropTypes.string.isRequired,
times: PropTypes.arrayOf(PropTypes.string).isRequired,
onEventPress: PropTypes.func,
59 changes: 11 additions & 48 deletions src/WeekView/WeekView.js
Original file line number Diff line number Diff line change
@@ -14,12 +14,12 @@ import { GestureHandlerRootView } from 'react-native-gesture-handler';
import moment from 'moment';
import memoizeOne from 'memoize-one';

import { EditEventConfigPropType, eventPropType } from '../Event/Event';
import Events, { GridRowPropType, GridColumnPropType } from '../Events/Events';
import Events from '../Events/Events';
import Header from '../Header/Header';
import Title from '../Title/Title';
import Times from '../Times/Times';
import styles from './WeekView.styles';
import bucketEventsByDate from '../pipeline/box';
import {
DATE_STR_FORMAT,
availableNumberOfDays,
@@ -31,6 +31,12 @@ import {
computeVerticalDimensions,
computeHorizontalDimensions,
} from '../utils/dimensions';
import {
GridRowPropType,
GridColumnPropType,
EditEventConfigPropType,
EventPropType,
} from '../utils/types';

const MINUTES_IN_DAY = 60 * 24;
const calculateTimesArray = (
@@ -444,50 +450,7 @@ export default class WeekView extends Component {
this.header = ref;
};

sortEventsByDate = memoizeOne((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;
});
bucketEventsByDate = memoizeOne(bucketEventsByDate);

getListItemLayout = (item, index) => {
const pageWidth = this.dimensions.pageWidth || 0;
@@ -553,7 +516,7 @@ export default class WeekView extends Component {
beginAgendaAt,
endAgendaAt,
);
const eventsByDate = this.sortEventsByDate(events);
const eventsByDate = this.bucketEventsByDate(events);
const horizontalInverted =
(prependMostRecent && !rightToLeft) ||
(!prependMostRecent && rightToLeft);
@@ -727,7 +690,7 @@ export default class WeekView extends Component {
}

WeekView.propTypes = {
events: PropTypes.arrayOf(eventPropType),
events: PropTypes.arrayOf(EventPropType),
formatDateHeader: PropTypes.string,
numberOfDays: PropTypes.oneOf(availableNumberOfDays).isRequired,
timesColumnWidth: PropTypes.number,
49 changes: 49 additions & 0 deletions src/pipeline/box.js
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;
185 changes: 185 additions & 0 deletions src/pipeline/overlap.js
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;
51 changes: 51 additions & 0 deletions src/pipeline/position.js
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,
);
2 changes: 1 addition & 1 deletion src/utils/dates.js
Original file line number Diff line number Diff line change
@@ -28,7 +28,7 @@ export const getCurrentMonth = (date) => {
*/
export const minutesInDay = (date) => {
const dateObj = moment(date);
if (!dateObj.isValid()) return 0;
if (!dateObj || !dateObj.isValid()) return 0;
return dateObj.hours() * 60 + dateObj.minutes();
};

50 changes: 50 additions & 0 deletions src/utils/types.js
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,
});

0 comments on commit b9dbec5

Please sign in to comment.