diff --git a/examples/App.js b/examples/App.js index 358e8d9e4..d37721a18 100644 --- a/examples/App.js +++ b/examples/App.js @@ -17,6 +17,7 @@ import Card from './Card' import ExampleControlSlot from './ExampleControlSlot' import Basic from './demos/basic' import Selectable from './demos/selectable' +import CreateEventWithNoOverlap from './demos/createEventWithNoOverlap' import Cultures from './demos/cultures' import Popup from './demos/popup' import Rendering from './demos/rendering' @@ -37,6 +38,7 @@ let demoRoot = const EXAMPLES = { basic: 'Basic Calendar', selectable: 'Create events', + createEventWithNoOverlap: 'Create events with no-overlap algorithm', cultures: 'Localization', popup: 'Show more via a popup', timeslots: 'Custom Time Grids', @@ -82,6 +84,7 @@ class Example extends React.Component { dnd: Dnd, dndresource: DndResource, dndOutsideSource: DndOutsideSource, + createEventWithNoOverlap: CreateEventWithNoOverlap, }[selected] return ( diff --git a/examples/demos/createEventWithNoOverlap.js b/examples/demos/createEventWithNoOverlap.js new file mode 100644 index 000000000..f35ef5c37 --- /dev/null +++ b/examples/demos/createEventWithNoOverlap.js @@ -0,0 +1,64 @@ +import React from 'react' +import { Calendar, Views } from 'react-big-calendar' +import events from '../events' +import ExampleControlSlot from '../ExampleControlSlot' +import _ from 'lodash' + +const propTypes = {} + +class CreateEventWithNoOverlap extends React.Component { + constructor(...args) { + super(...args) + + this.state = { + events: _.cloneDeep(events), + dayLayoutAlgorithm: 'no-overlap', + } + } + + handleSelect = ({ start, end }) => { + const title = window.prompt('New Event name') + if (title) + this.setState({ + events: [ + ...this.state.events, + { + start, + end, + title, + }, + ], + }) + } + + render() { + const { localizer } = this.props + return ( + <> + + + Click an event to see more info, or drag the mouse over the calendar + to select a date/time range. +
+ The events are being arranged by `no-overlap` algorithm. +
+
+ alert(event.title)} + onSelectSlot={this.handleSelect} + dayLayoutAlgorithm={this.state.dayLayoutAlgorithm} + /> + + ) + } +} + +CreateEventWithNoOverlap.propTypes = propTypes + +export default CreateEventWithNoOverlap diff --git a/examples/events.js b/examples/events.js index 5be55c81b..08e377027 100644 --- a/examples/events.js +++ b/examples/events.js @@ -111,4 +111,52 @@ export default [ start: now, end: now, }, + { + id: 16, + title: 'Video Record', + start: new Date(2015, 3, 14, 15, 30, 0), + end: new Date(2015, 3, 14, 19, 0, 0), + }, + { + id: 17, + title: 'Dutch Song Producing', + start: new Date(2015, 3, 14, 16, 30, 0), + end: new Date(2015, 3, 14, 20, 0, 0), + }, + { + id: 18, + title: 'Itaewon Halloween Meeting', + start: new Date(2015, 3, 14, 16, 30, 0), + end: new Date(2015, 3, 14, 17, 30, 0), + }, + { + id: 19, + title: 'Online Coding Test', + start: new Date(2015, 3, 14, 17, 30, 0), + end: new Date(2015, 3, 14, 20, 30, 0), + }, + { + id: 20, + title: 'An overlapped Event', + start: new Date(2015, 3, 14, 17, 0, 0), + end: new Date(2015, 3, 14, 18, 30, 0), + }, + { + id: 21, + title: 'Phone Interview', + start: new Date(2015, 3, 14, 17, 0, 0), + end: new Date(2015, 3, 14, 18, 30, 0), + }, + { + id: 22, + title: 'Cooking Class', + start: new Date(2015, 3, 14, 17, 30, 0), + end: new Date(2015, 3, 14, 19, 0, 0), + }, + { + id: 23, + title: 'Go to the gym', + start: new Date(2015, 3, 14, 18, 30, 0), + end: new Date(2015, 3, 14, 20, 0, 0), + }, ] diff --git a/src/Calendar.js b/src/Calendar.js index 0111d180b..91cbbc299 100644 --- a/src/Calendar.js +++ b/src/Calendar.js @@ -6,6 +6,7 @@ import { accessor, dateFormat, dateRangeFormat, + DayLayoutAlgorithmPropType, views as componentViews, } from './utils/propTypes' import warning from 'warning' @@ -719,6 +720,14 @@ class Calendar extends React.Component { noEventsInRange: PropTypes.node, showMore: PropTypes.func, }), + + /** + * A day event layout(arrangement) algorithm. + * `overlap` allows events to be overlapped. + * `no-overlap` resizes events to avoid overlap. + * or custom `Function(events, minimumStartDifference, slotMetrics, accessors)` + */ + dayLayoutAlgorithm: DayLayoutAlgorithmPropType, } static defaultProps = { @@ -744,6 +753,7 @@ class Calendar extends React.Component { longPressThreshold: 250, getNow: () => new Date(), + dayLayoutAlgorithm: 'overlap', } constructor(...args) { diff --git a/src/DayColumn.js b/src/DayColumn.js index 0bd09a0a9..0e508f2a8 100644 --- a/src/DayColumn.js +++ b/src/DayColumn.js @@ -12,9 +12,11 @@ import { notify } from './utils/helpers' import * as DayEventLayout from './utils/DayEventLayout' import TimeSlotGroup from './TimeSlotGroup' import TimeGridEvent from './TimeGridEvent' +import { DayLayoutAlgorithmPropType } from './utils/propTypes' class DayColumn extends React.Component { state = { selecting: false, timeIndicatorPosition: null } + intervalTriggered = false constructor(...args) { super(...args) @@ -70,7 +72,6 @@ class DayColumn extends React.Component { } } - intervalTriggered = false /** * @param tail {Boolean} - whether `positionTimeIndicator` call should be * deferred or called upon setting interval (`true` - if deferred); @@ -183,6 +184,7 @@ class DayColumn extends React.Component { components, step, timeslots, + dayLayoutAlgorithm, } = this.props const { slotMetrics } = this @@ -193,6 +195,7 @@ class DayColumn extends React.Component { accessors, slotMetrics, minimumStartDifference: Math.ceil((step * timeslots) / 2), + dayLayoutAlgorithm, }) return styledEvents.map(({ event, style }, idx) => { @@ -402,6 +405,8 @@ DayColumn.propTypes = { className: PropTypes.string, dragThroughEvents: PropTypes.bool, resource: PropTypes.any, + + dayLayoutAlgorithm: DayLayoutAlgorithmPropType, } DayColumn.defaultProps = { diff --git a/src/TimeGrid.js b/src/TimeGrid.js index d28507861..d0379cfef 100644 --- a/src/TimeGrid.js +++ b/src/TimeGrid.js @@ -14,6 +14,7 @@ import TimeGridHeader from './TimeGridHeader' import { notify } from './utils/helpers' import { inRange, sortEvents } from './utils/eventLevels' import Resources from './utils/Resources' +import { DayLayoutAlgorithmPropType } from './utils/propTypes' export default class TimeGrid extends Component { constructor(props) { @@ -103,7 +104,14 @@ export default class TimeGrid extends Component { } renderEvents(range, events, now) { - let { min, max, components, accessors, localizer } = this.props + let { + min, + max, + components, + accessors, + localizer, + dayLayoutAlgorithm, + } = this.props const resources = this.memoizedResources(this.props.resources, accessors) const groupedEvents = resources.groupEvents(events) @@ -131,6 +139,7 @@ export default class TimeGrid extends Component { key={i + '-' + jj} date={date} events={daysEvents} + dayLayoutAlgorithm={dayLayoutAlgorithm} /> ) }) @@ -328,6 +337,8 @@ TimeGrid.propTypes = { onDoubleClickEvent: PropTypes.func, onDrillDown: PropTypes.func, getDrilldownView: PropTypes.func.isRequired, + + dayLayoutAlgorithm: DayLayoutAlgorithmPropType, } TimeGrid.defaultProps = { diff --git a/src/TimeGridEvent.js b/src/TimeGridEvent.js index 4fbd27a88..b0ccfbaa9 100644 --- a/src/TimeGridEvent.js +++ b/src/TimeGridEvent.js @@ -1,6 +1,10 @@ import clsx from 'clsx' import React from 'react' +function stringifyPercent(v) { + return typeof v === 'string' ? v : v + '%' +} + /* eslint-disable react/prop-types */ function TimeGridEvent(props) { const { @@ -42,10 +46,10 @@ function TimeGridEvent(props) { onDoubleClick={onDoubleClick} style={{ ...userProps.style, - top: `${top}%`, - height: `${height}%`, - [rtl ? 'right' : 'left']: `${Math.max(0, xOffset)}%`, - width: `${width}%`, + top: stringifyPercent(top), + [rtl ? 'right' : 'left']: stringifyPercent(xOffset), + width: stringifyPercent(width), + height: stringifyPercent(height), }} title={ tooltip diff --git a/src/utils/DayEventLayout.js b/src/utils/DayEventLayout.js index 80610c45a..4ee883772 100644 --- a/src/utils/DayEventLayout.js +++ b/src/utils/DayEventLayout.js @@ -1,200 +1,35 @@ -import sortBy from 'lodash/sortBy' +/*eslint no-unused-vars: "off"*/ -class Event { - constructor(data, { accessors, slotMetrics }) { - const { - start, - startDate, - end, - endDate, - top, - height, - } = slotMetrics.getRange(accessors.start(data), accessors.end(data)) +import overlap from './layout-algorithms/overlap' +import noOverlap from './layout-algorithms/no-overlap' - this.start = start - this.end = end - this.startMs = +startDate - this.endMs = +endDate - this.top = top - this.height = height - this.data = data - } - - /** - * The event's width without any overlap. - */ - get _width() { - // The container event's width is determined by the maximum number of - // events in any of its rows. - if (this.rows) { - const columns = - this.rows.reduce( - (max, row) => Math.max(max, row.leaves.length + 1), // add itself - 0 - ) + 1 // add the container - - return 100 / columns - } - - const availableWidth = 100 - this.container._width - - // The row event's width is the space left by the container, divided - // among itself and its leaves. - if (this.leaves) { - return availableWidth / (this.leaves.length + 1) - } - - // The leaf event's width is determined by its row's width - return this.row._width - } - - /** - * The event's calculated width, possibly with extra width added for - * overlapping effect. - */ - get width() { - const noOverlap = this._width - const overlap = Math.min(100, this._width * 1.7) - - // Containers can always grow. - if (this.rows) { - return overlap - } - - // Rows can grow if they have leaves. - if (this.leaves) { - return this.leaves.length > 0 ? overlap : noOverlap - } - - // Leaves can grow unless they're the last item in a row. - const { leaves } = this.row - const index = leaves.indexOf(this) - return index === leaves.length - 1 ? noOverlap : overlap - } - - get xOffset() { - // Containers have no offset. - if (this.rows) return 0 - - // Rows always start where their container ends. - if (this.leaves) return this.container._width - - // Leaves are spread out evenly on the space left by its row. - const { leaves, xOffset, _width } = this.row - const index = leaves.indexOf(this) + 1 - return xOffset + index * _width - } +const DefaultAlgorithms = { + overlap: overlap, + 'no-overlap': noOverlap, } -/** - * Return true if event a and b is considered to be on the same row. - */ -function onSameRow(a, b, minimumStartDifference) { - return ( - // Occupies the same start slot. - Math.abs(b.start - a.start) < minimumStartDifference || - // A's start slot overlaps with b's end slot. - (b.start > a.start && b.start < a.end) - ) +function isFunction(a) { + return !!(a && a.constructor && a.call && a.apply) } -function sortByRender(events) { - const sortedByTime = sortBy(events, ['startMs', e => -e.endMs]) - - const sorted = [] - while (sortedByTime.length > 0) { - const event = sortedByTime.shift() - sorted.push(event) - - for (let i = 0; i < sortedByTime.length; i++) { - const test = sortedByTime[i] - - // Still inside this event, look for next. - if (event.endMs > test.startMs) continue - - // We've found the first event of the next event group. - // If that event is not right next to our current event, we have to - // move it here. - if (i > 0) { - const event = sortedByTime.splice(i, 1)[0] - sorted.push(event) - } - - // We've already found the next event group, so stop looking. - break - } - } - - return sorted -} - -function getStyledEvents({ +// +export function getStyledEvents({ events, minimumStartDifference, slotMetrics, accessors, + dayLayoutAlgorithm, // one of DefaultAlgorithms keys + // or custom function }) { - // Create proxy events and order them so that we don't have - // to fiddle with z-indexes. - const proxies = events.map( - event => new Event(event, { slotMetrics, accessors }) - ) - const eventsInRenderOrder = sortByRender(proxies) - - // Group overlapping events, while keeping order. - // Every event is always one of: container, row or leaf. - // Containers can contain rows, and rows can contain leaves. - const containerEvents = [] - for (let i = 0; i < eventsInRenderOrder.length; i++) { - const event = eventsInRenderOrder[i] - - // Check if this event can go into a container event. - const container = containerEvents.find( - c => - c.end > event.start || - Math.abs(event.start - c.start) < minimumStartDifference - ) + let algorithm = null - // Couldn't find a container — that means this event is a container. - if (!container) { - event.rows = [] - containerEvents.push(event) - continue - } + if (dayLayoutAlgorithm in DefaultAlgorithms) + algorithm = DefaultAlgorithms[dayLayoutAlgorithm] - // Found a container for the event. - event.container = container - - // Check if the event can be placed in an existing row. - // Start looking from behind. - let row = null - for (let j = container.rows.length - 1; !row && j >= 0; j--) { - if (onSameRow(container.rows[j], event, minimumStartDifference)) { - row = container.rows[j] - } - } - - if (row) { - // Found a row, so add it. - row.leaves.push(event) - event.row = row - } else { - // Couldn't find a row – that means this event is a row. - event.leaves = [] - container.rows.push(event) - } + if (!isFunction(algorithm)) { + // invalid algorithm + return [] } - // Return the original events, along with their styles. - return eventsInRenderOrder.map(event => ({ - event: event.data, - style: { - top: event.top, - height: event.height, - width: event.width, - xOffset: event.xOffset, - }, - })) + return algorithm.apply(this, arguments) } - -export { getStyledEvents } diff --git a/src/utils/layout-algorithms/no-overlap.js b/src/utils/layout-algorithms/no-overlap.js new file mode 100644 index 000000000..94bc4f3bc --- /dev/null +++ b/src/utils/layout-algorithms/no-overlap.js @@ -0,0 +1,108 @@ +import overlap from './overlap' + +function getMaxIdxDFS(node, maxIdx, visited) { + for (let i = 0; i < node.friends.length; ++i) { + if (visited.indexOf(node.friends[i]) > -1) continue + maxIdx = maxIdx > node.friends[i].idx ? maxIdx : node.friends[i].idx + // TODO : trace it by not object but kinda index or something for performance + visited.push(node.friends[i]) + const newIdx = getMaxIdxDFS(node.friends[i], maxIdx, visited) + maxIdx = maxIdx > newIdx ? maxIdx : newIdx + } + return maxIdx +} + +export default function({ + events, + minimumStartDifference, + slotMetrics, + accessors, +}) { + const styledEvents = overlap({ + events, + minimumStartDifference, + slotMetrics, + accessors, + }) + + styledEvents.sort((a, b) => { + a = a.style + b = b.style + if (a.top !== b.top) return a.top > b.top ? 1 : -1 + else return a.top + a.height < b.top + b.height ? 1 : -1 + }) + + for (let i = 0; i < styledEvents.length; ++i) { + styledEvents[i].friends = [] + delete styledEvents[i].style.left + delete styledEvents[i].style.left + delete styledEvents[i].idx + delete styledEvents[i].size + } + + for (let i = 0; i < styledEvents.length - 1; ++i) { + const se1 = styledEvents[i] + const y1 = se1.style.top + const y2 = se1.style.top + se1.style.height + + for (let j = i + 1; j < styledEvents.length; ++j) { + const se2 = styledEvents[j] + const y3 = se2.style.top + const y4 = se2.style.top + se2.style.height + + // be friends when overlapped + if ((y3 <= y1 && y1 < y4) || (y1 <= y3 && y3 < y2)) { + // TODO : hashmap would be effective for performance + se1.friends.push(se2) + se2.friends.push(se1) + } + } + } + + for (let i = 0; i < styledEvents.length; ++i) { + const se = styledEvents[i] + const bitmap = [] + for (let j = 0; j < 100; ++j) bitmap.push(1) // 1 means available + + for (let j = 0; j < se.friends.length; ++j) + if (se.friends[j].idx !== undefined) bitmap[se.friends[j].idx] = 0 // 0 means reserved + + se.idx = bitmap.indexOf(1) + } + + for (let i = 0; i < styledEvents.length; ++i) { + let size = 0 + + if (styledEvents[i].size) continue + + const allFriends = [] + const maxIdx = getMaxIdxDFS(styledEvents[i], 0, allFriends) + size = 100 / (maxIdx + 1) + styledEvents[i].size = size + + for (let j = 0; j < allFriends.length; ++j) allFriends[j].size = size + } + + for (let i = 0; i < styledEvents.length; ++i) { + const e = styledEvents[i] + e.style.left = e.idx * e.size + + // stretch to maximum + let maxIdx = 0 + for (let j = 0; j < e.friends.length; ++j) { + const idx = e.friends[j] + maxIdx = maxIdx > idx ? maxIdx : idx + } + if (maxIdx <= e.idx) e.size = 100 - e.idx * e.size + + // padding between events + // for this feature, `width` is not percentage based unit anymore + // it will be used with calc() + const padding = e.idx === 0 ? 0 : 3 + e.style.width = `calc(${e.size}% - ${padding}px)` + e.style.height = `calc(${e.style.height}% - 2px)` + e.style.xOffset = `calc(${e.style.left}% + ${padding}px)` + } + + return styledEvents +} diff --git a/src/utils/layout-algorithms/overlap.js b/src/utils/layout-algorithms/overlap.js new file mode 100644 index 000000000..b3fa5a200 --- /dev/null +++ b/src/utils/layout-algorithms/overlap.js @@ -0,0 +1,198 @@ +import sortBy from 'lodash/sortBy' + +class Event { + constructor(data, { accessors, slotMetrics }) { + const { + start, + startDate, + end, + endDate, + top, + height, + } = slotMetrics.getRange(accessors.start(data), accessors.end(data)) + + this.start = start + this.end = end + this.startMs = +startDate + this.endMs = +endDate + this.top = top + this.height = height + this.data = data + } + + /** + * The event's width without any overlap. + */ + get _width() { + // The container event's width is determined by the maximum number of + // events in any of its rows. + if (this.rows) { + const columns = + this.rows.reduce( + (max, row) => Math.max(max, row.leaves.length + 1), // add itself + 0 + ) + 1 // add the container + + return 100 / columns + } + + const availableWidth = 100 - this.container._width + + // The row event's width is the space left by the container, divided + // among itself and its leaves. + if (this.leaves) { + return availableWidth / (this.leaves.length + 1) + } + + // The leaf event's width is determined by its row's width + return this.row._width + } + + /** + * The event's calculated width, possibly with extra width added for + * overlapping effect. + */ + get width() { + const noOverlap = this._width + const overlap = Math.min(100, this._width * 1.7) + + // Containers can always grow. + if (this.rows) { + return overlap + } + + // Rows can grow if they have leaves. + if (this.leaves) { + return this.leaves.length > 0 ? overlap : noOverlap + } + + // Leaves can grow unless they're the last item in a row. + const { leaves } = this.row + const index = leaves.indexOf(this) + return index === leaves.length - 1 ? noOverlap : overlap + } + + get xOffset() { + // Containers have no offset. + if (this.rows) return 0 + + // Rows always start where their container ends. + if (this.leaves) return this.container._width + + // Leaves are spread out evenly on the space left by its row. + const { leaves, xOffset, _width } = this.row + const index = leaves.indexOf(this) + 1 + return xOffset + index * _width + } +} + +/** + * Return true if event a and b is considered to be on the same row. + */ +function onSameRow(a, b, minimumStartDifference) { + return ( + // Occupies the same start slot. + Math.abs(b.start - a.start) < minimumStartDifference || + // A's start slot overlaps with b's end slot. + (b.start > a.start && b.start < a.end) + ) +} + +function sortByRender(events) { + const sortedByTime = sortBy(events, ['startMs', e => -e.endMs]) + + const sorted = [] + while (sortedByTime.length > 0) { + const event = sortedByTime.shift() + sorted.push(event) + + for (let i = 0; i < sortedByTime.length; i++) { + const test = sortedByTime[i] + + // Still inside this event, look for next. + if (event.endMs > test.startMs) continue + + // We've found the first event of the next event group. + // If that event is not right next to our current event, we have to + // move it here. + if (i > 0) { + const event = sortedByTime.splice(i, 1)[0] + sorted.push(event) + } + + // We've already found the next event group, so stop looking. + break + } + } + + return sorted +} + +export default function getStyledEvents({ + events, + minimumStartDifference, + slotMetrics, + accessors, +}) { + // Create proxy events and order them so that we don't have + // to fiddle with z-indexes. + const proxies = events.map( + event => new Event(event, { slotMetrics, accessors }) + ) + const eventsInRenderOrder = sortByRender(proxies) + + // Group overlapping events, while keeping order. + // Every event is always one of: container, row or leaf. + // Containers can contain rows, and rows can contain leaves. + const containerEvents = [] + for (let i = 0; i < eventsInRenderOrder.length; i++) { + const event = eventsInRenderOrder[i] + + // Check if this event can go into a container event. + const container = containerEvents.find( + c => + c.end > event.start || + Math.abs(event.start - c.start) < minimumStartDifference + ) + + // Couldn't find a container — that means this event is a container. + if (!container) { + event.rows = [] + containerEvents.push(event) + continue + } + + // Found a container for the event. + event.container = container + + // Check if the event can be placed in an existing row. + // Start looking from behind. + let row = null + for (let j = container.rows.length - 1; !row && j >= 0; j--) { + if (onSameRow(container.rows[j], event, minimumStartDifference)) { + row = container.rows[j] + } + } + + if (row) { + // Found a row, so add it. + row.leaves.push(event) + event.row = row + } else { + // Couldn't find a row – that means this event is a row. + event.leaves = [] + container.rows.push(event) + } + } + + // Return the original events, along with their styles. + return eventsInRenderOrder.map(event => ({ + event: event.data, + style: { + top: event.top, + height: event.height, + width: event.width, + xOffset: Math.max(0, event.xOffset), + }, + })) +} diff --git a/src/utils/propTypes.js b/src/utils/propTypes.js index b95c524ee..b08bd5788 100644 --- a/src/utils/propTypes.js +++ b/src/utils/propTypes.js @@ -39,3 +39,8 @@ export let views = PropTypes.oneOfType([ } }), ]) + +export const DayLayoutAlgorithmPropType = PropTypes.oneOfType([ + PropTypes.oneOf(['overlap', 'no-overlap']), + PropTypes.func, +]) diff --git a/test/utils/DayEventLayout.test.js b/test/utils/DayEventLayout.test.js index f6505b1a3..927c69728 100644 --- a/test/utils/DayEventLayout.test.js +++ b/test/utils/DayEventLayout.test.js @@ -8,6 +8,7 @@ describe('getStyledEvents', () => { const max = dates.endOf(d(), 'day') const slotMetrics = getSlotMetrics({ min, max, step: 30, timeslots: 4 }) const accessors = { start: e => e.start, end: e => e.end } + const dayLayoutAlgorithm = 'overlap' describe('matrix', () => { function compare(title, events, expectedResults) { @@ -17,6 +18,7 @@ describe('getStyledEvents', () => { accessors, slotMetrics, minimumStartDifference: 10, + dayLayoutAlgorithm, }) const results = styledEvents.map(result => ({ width: Math.floor(result.style.width),