Skip to content

Commit

Permalink
feat(calendar): add TimeRange component and storybook (#1503)
Browse files Browse the repository at this point in the history
scherler authored May 29, 2021
1 parent fadeb0e commit 8245fbd
Showing 9 changed files with 840 additions and 6 deletions.
69 changes: 63 additions & 6 deletions packages/calendar/index.d.ts
Original file line number Diff line number Diff line change
@@ -47,7 +47,7 @@ declare module '@nivo/calendar' {
}

export interface ColorScale {
(value: number | { valueOf(): number }): Range
(value: number | { valueOf(): number }): any[]
ticks(count?: number): number[]
}

@@ -66,10 +66,10 @@ declare module '@nivo/calendar' {
yearLegendOffset: number
yearLegendPosition: 'before' | 'after'

monthLegend: (year: number, month: number, date: Date) => string | number
monthSpacing: number
monthBorderWidth: number
monthBorderColor: string
monthLegend: (year: number, month: number, date: Date) => string | number
monthLegendOffset: number
monthLegendPosition: 'before' | 'after'

@@ -102,8 +102,65 @@ declare module '@nivo/calendar' {
role: string
}>

export class Calendar extends React.Component<CalendarSvgProps & Dimensions> {}
export class ResponsiveCalendar extends React.Component<CalendarSvgProps> {}
export class CalendarCanvas extends React.Component<CalendarSvgProps & Dimensions> {}
export class ResponsiveCalendarCanvas extends React.Component<CalendarSvgProps> {}
export class Calendar extends React.Component<CalendarSvgProps & Dimensions> { }
export class ResponsiveCalendar extends React.Component<CalendarSvgProps> { }
export class CalendarCanvas extends React.Component<CalendarSvgProps & Dimensions> { }
export class ResponsiveCalendarCanvas extends React.Component<CalendarSvgProps> { }

export type TimeRangeCommonProps = Partial<{
minValue: 'auto' | number
maxValue: 'auto' | number
direction: CalendarDirection
colors: string[]
colorScale: ColorScale
margin: Box
square?: boolean
daySpacing: number
dayRadius: number
dayBorderWidth: number
dayBorderColor: string
emptyColor: string
isInteractive: boolean
onClick?: CalendarMouseHandler
onMouseMove?: CalendarMouseHandler
onMouseLeave?: CalendarMouseHandler
onMouseEnter?: CalendarMouseHandler
tooltip: React.FunctionComponent<CalendarDayData>
valueFormat?: string | ValueFormatter
legendFormat?: string | ValueFormatter
legends: CalendarLegend[]
theme: Theme
weekdayLegendsOffset: number
monthLegend: (year: number, month: number, date: Date) => string | number
monthLegendOffset: number
monthLegendPosition: 'before' | 'after'
}>

export interface TimeRangeDatum {
day: string
date: Date
value: number
}

export interface TimeRangeData {
from: Date
to: Date
data: TimeRangeDatum[]
}
export type TimeRangeProps = TimeRangeData &
TimeRangeCommonProps &
Partial<{
onClick: (datum: CalendarDayData, event: React.MouseEvent<SVGRectElement>) => void
role: string
}> &
Dimensions

export type TimeRangeSvgProps = TimeRangeData &
TimeRangeCommonProps &
Partial<{
onClick: (datum: CalendarDayData, event: React.MouseEvent<SVGRectElement>) => void
role: string
}>

export class ResponsiveTimeRange extends React.Component<TimeRangeSvgProps> { }
}
11 changes: 11 additions & 0 deletions packages/calendar/src/ResponsiveTimeRange.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import React from 'react'
import { ResponsiveWrapper } from '@nivo/core'
import TimeRange from './TimeRange'

const ResponsiveTimeRange = props => (
<ResponsiveWrapper>
{({ width, height }) => <TimeRange width={width} height={height} {...props} />}
</ResponsiveWrapper>
)

export default ResponsiveTimeRange
181 changes: 181 additions & 0 deletions packages/calendar/src/TimeRange.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import React from 'react'
import { timeFormat } from 'd3-time-format'

import { SvgWrapper, withContainer, useValueFormatter, useTheme, useDimensions } from '@nivo/core'
import { BoxLegendSvg } from '@nivo/legends'

import {
Direction,
computeWeekdays,
computeCellSize,
computeCellPositions,
computeMonthLegends,
} from './compute-timeRange'

import { useMonthLegends, useColorScale } from './hooks'
import TimeRangeDay from './TimeRangeDay'
import CalendarTooltip from './CalendarTooltip'
import CalendarMonthLegends from './CalendarMonthLegends'

const monthLabelFormat = timeFormat('%b')

const TimeRange = ({
margin: partialMargin,
width,
height,

square,
colors = ['#61cdbb', '#97e3d5', '#e8c1a0', '#f47560'],
colorScale,
data,
direction = Direction.HORIZONTAL,
minValue = 0,
maxValue = 'auto',
valueFormat,
legendFormat,
role,
tooltip = CalendarTooltip,
onClick,
onMouseEnter,
onMouseLeave,
onMouseMove,
isInteractive = true,
legends = [],
dayBorderColor = '#fff',
dayBorderWidth = 0,
dayRadius = 0,
daySpacing,
daysInRange,
weekdayLegendsOffset,
monthLegend = (_year, _month, date) => monthLabelFormat(date),
monthLegendOffset = 0,
monthLegendPosition = 'before',
}) => {
const { margin, innerWidth, innerHeight, outerWidth, outerHeight } = useDimensions(
width,
height,
partialMargin
)

const theme = useTheme()
const colorScaleFn = useColorScale({ data, minValue, maxValue, colors, colorScale })

const { cellHeight, cellWidth } = computeCellSize({
square,
offset: weekdayLegendsOffset,
totalDays: data.length + data[0].date.getDay(),
width: innerWidth,
height: innerHeight,
daySpacing,
direction,
})

const days = computeCellPositions({
offset: weekdayLegendsOffset,
daysInRange,
colorScale: colorScaleFn,
cellHeight,
cellWidth,
data,
direction,
daySpacing,
})

// map the days and reduce the month
const months = Object.values(
computeMonthLegends({
daySpacing,
direction,
cellHeight,
cellWidth,
days,
daysInRange,
}).months
)

const weekdayLegends = computeWeekdays({
direction,
cellHeight,
cellWidth,
daySpacing,
})

const monthLegends = useMonthLegends({
months,
direction,
monthLegendPosition,
monthLegendOffset,
})

const formatValue = useValueFormatter(valueFormat)
const formatLegend = useValueFormatter(legendFormat)

return (
<SvgWrapper
width={outerWidth}
height={outerHeight}
margin={margin}
role={role}
theme={theme}
>
{weekdayLegends.map(legend => (
<text
key={legend.value}
transform={`translate(${legend.x},${legend.y}) rotate(${legend.rotation})`}
textAnchor="left"
style={theme.labels.text}
>
{legend.value}
</text>
))}
{days.map(d => {
return (
<TimeRangeDay
key={d.day.toString()}
data={d}
x={d.coordinates.x}
rx={dayRadius}
y={d.coordinates.y}
ry={dayRadius}
spacing={daySpacing}
width={cellWidth}
height={cellHeight}
color={d.color}
borderWidth={dayBorderWidth}
borderColor={dayBorderColor}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onMouseMove={onMouseMove}
isInteractive={isInteractive}
tooltip={tooltip}
theme={theme}
onClick={onClick}
formatValue={formatValue}
/>
)
})}
<CalendarMonthLegends months={monthLegends} legend={monthLegend} theme={theme} />

{legends?.map((legend, i) => {
const legendData = colorScaleFn.ticks(legend.itemCount).map(value => ({
id: value,
label: formatLegend(value),
color: colorScaleFn(value),
}))

return (
<BoxLegendSvg
key={i}
{...legend}
containerWidth={width}
containerHeight={height}
data={legendData}
theme={theme}
/>
)
})}
</SvgWrapper>
)
}

export default withContainer(TimeRange)
119 changes: 119 additions & 0 deletions packages/calendar/src/TimeRangeDay.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/*
* This file is part of the nivo project.
*
* Copyright 2016-present, Raphaël Benitte.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import React, { memo, useCallback } from 'react'
import PropTypes from 'prop-types'
import { useTooltip } from '@nivo/tooltip'
import CalendarTooltip from './CalendarTooltip'

const TimeRangeDay = memo(
({
data,
x,
ry = 5,
rx = 5,
y,
width,
height,
color,
borderWidth,
borderColor,
isInteractive,
tooltip = CalendarTooltip,
onMouseEnter,
onMouseMove,
onMouseLeave,
onClick,
formatValue,
}) => {
const { showTooltipFromEvent, hideTooltip } = useTooltip()

const handleMouseEnter = useCallback(
event => {
const formatedData = {
...data,
value: formatValue(data.value),
data: { ...data.data },
}
showTooltipFromEvent(React.createElement(tooltip, { ...formatedData }), event)
onMouseEnter && onMouseEnter(data, event)
},
[showTooltipFromEvent, tooltip, data, onMouseEnter, formatValue]
)
const handleMouseMove = useCallback(
event => {
const formatedData = {
...data,
value: formatValue(data.value),
data: { ...data.data },
}
showTooltipFromEvent(React.createElement(tooltip, { ...formatedData }), event)
onMouseMove && onMouseMove(data, event)
},
[showTooltipFromEvent, tooltip, data, onMouseMove, formatValue]
)
const handleMouseLeave = useCallback(
event => {
hideTooltip()
onMouseLeave && onMouseLeave(data, event)
},
[isInteractive, hideTooltip, data, onMouseLeave]
)
const handleClick = useCallback(event => onClick && onClick(data, event), [
isInteractive,
data,
onClick,
])
return (
<rect
x={x}
y={y}
rx={rx}
ry={ry}
width={width}
height={height}
style={{
fill: color,
strokeWidth: borderWidth,
stroke: borderColor,
}}
onMouseEnter={isInteractive ? handleMouseEnter : undefined}
onMouseMove={isInteractive ? handleMouseMove : undefined}
onMouseLeave={isInteractive ? handleMouseLeave : undefined}
onClick={isInteractive ? handleClick : undefined}
/>
)
}
)

TimeRangeDay.displayName = 'TimeRangeDay'
TimeRangeDay.propTypes = {
onClick: PropTypes.func,
onMouseEnter: PropTypes.func,
onMouseLeave: PropTypes.func,
onMouseMove: PropTypes.func,
data: PropTypes.object.isRequired,
x: PropTypes.number.isRequired,
y: PropTypes.number.isRequired,
width: PropTypes.number.isRequired,
height: PropTypes.number.isRequired,
spacing: PropTypes.number.isRequired,
color: PropTypes.string.isRequired,
borderWidth: PropTypes.number.isRequired,
borderColor: PropTypes.string.isRequired,
isInteractive: PropTypes.bool.isRequired,
formatValue: PropTypes.func,

tooltip: PropTypes.oneOfType([PropTypes.func, PropTypes.object]).isRequired,

theme: PropTypes.shape({
tooltip: PropTypes.shape({}).isRequired,
}).isRequired,
}

export default TimeRangeDay
279 changes: 279 additions & 0 deletions packages/calendar/src/compute-timeRange.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
import { timeWeek } from 'd3-time'

export enum Direction {
VERTICAL = 'vertical',
HORIZONTAL = 'horizontal',
}

export const defaultProps = {
daysInRange: 7,
direction: Direction.HORIZONTAL,
daySpacing: 10,
offset: 65,
square: true,
}

// Interfaces
export interface ComputeBaseProps {
daysInRange?: number
direction?: Direction
}

export interface ComputeBaseSpaceProps {
daySpacing?: number
offset?: number
}

export interface ComputeBaseDimensionProps {
cellWidth: number
cellHeight: number
}

export interface ComputeCellSize extends ComputeBaseProps, ComputeBaseSpaceProps {
totalDays: number
width: number
height: number
square?: boolean
}

export interface ComputeCellPositions
extends ComputeBaseProps,
ComputeBaseSpaceProps,
ComputeBaseDimensionProps {
data: {
date: Date
day: string
value: number
}[]
colorScale: (value: number) => string
}

export interface ComputeWeekdays
extends ComputeBaseProps,
ComputeBaseSpaceProps,
ComputeBaseDimensionProps {
ticks?: number[]
arrayOfWeekdays?: string[]
}

export interface Day {
coordinates: {
x: number
y: number
}
firstWeek: number
month: number
year: number
date: Date
color: string
day: string
value: number
}

export interface Month {
date: Date
bbox: {
x: number
y: number
width: number
height: number
}
firstWeek: number
}
export interface ComputeMonths
extends ComputeBaseProps,
ComputeBaseSpaceProps,
ComputeBaseDimensionProps {
days: Day[]
}

/**
* Compute day cell size according to
* current context.
*/
export const computeCellSize = ({
direction = defaultProps.direction,
daysInRange = defaultProps.daysInRange,
daySpacing = defaultProps.daySpacing,
offset = defaultProps.offset,
square = defaultProps.square,
totalDays,
width,
height,
}: ComputeCellSize) => {
let rows
let columns
let widthRest = width
let heightRest = height
if (direction === Direction.HORIZONTAL) {
widthRest -= offset
rows = daysInRange
columns = Math.ceil(totalDays / daysInRange)
} else {
heightRest -= offset
columns = daysInRange
rows = Math.ceil(totalDays / daysInRange)
}
// + 1 since we have to apply spacing to the rigth and left
const cellHeight = (heightRest - daySpacing * (rows + 1)) / rows
const cellWidth = (widthRest - daySpacing * (columns + 1)) / columns
// do we want square?
const size = Math.min(cellHeight, cellWidth)
return {
columns,
rows,
cellHeight: square ? size : cellHeight,
cellWidth: square ? size : cellWidth,
}
}

function computeGrid({
startDate,
date,
direction,
}: {
startDate: Date
date: Date
direction: Direction
}) {
const firstWeek = timeWeek.count(startDate, date)
const month = date.getMonth()
const year = date.getFullYear()

let currentColumn = 0
let currentRow = 0
if (direction === Direction.HORIZONTAL) {
currentColumn = firstWeek
currentRow = date.getDay()
} else {
currentColumn = date.getDay()
currentRow = firstWeek
}

return { currentColumn, year, currentRow, firstWeek, month, date }
}

export const computeCellPositions = ({
direction = defaultProps.direction,
colorScale,
data,
cellWidth,
cellHeight,
daySpacing = defaultProps.daySpacing,
offset = defaultProps.offset,
}: ComputeCellPositions) => {
let x = daySpacing
let y = daySpacing

if (direction === Direction.HORIZONTAL) {
x += offset
} else {
y += offset
}

// we need to determine whether we need to add days to move to correct position
const startDate = data[0].date
const dataWithCellPosition = data.map(dateValue => {
const { currentColumn, currentRow, firstWeek, year, month, date } = computeGrid({
startDate,
date: dateValue.date,
direction,
})

const coordinates = {
x: x + daySpacing * currentColumn + cellWidth * currentColumn,
y: y + daySpacing * currentRow + cellHeight * currentRow,
}

return {
...dateValue,
coordinates,
firstWeek,
month,
year,
date,
color: colorScale(dateValue.value),
}
})

return dataWithCellPosition
}

export const computeWeekdays = ({
cellHeight,
cellWidth,
direction = defaultProps.direction,
daySpacing = defaultProps.daySpacing,
ticks = [1, 3, 5],
arrayOfWeekdays = [
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
],
}: ComputeWeekdays) => {
const sizes = {
width: cellWidth + daySpacing,
height: cellHeight + daySpacing,
}
return ticks.map(day => ({
value: arrayOfWeekdays[day],
rotation: direction === Direction.HORIZONTAL ? 0 : -90,
y: direction === Direction.HORIZONTAL ? sizes.height * (day + 1) - daySpacing / 2 : 0,
x: direction === Direction.HORIZONTAL ? 0 : sizes.width * (day + 1) - daySpacing / 2,
}))
}

export const computeMonthLegends = ({
direction = defaultProps.direction,
daySpacing = defaultProps.daySpacing,
daysInRange = defaultProps.daysInRange,
days,
cellHeight,
cellWidth,
}: ComputeMonths) => {
const accumulator: {
months: { [key: string]: Month }
weeks: Day[]
} = {
months: {},
weeks: [],
}
return days.reduce((acc, day) => {
if (acc.weeks.length === day.firstWeek) {
acc.weeks.push(day)
if (!Object.keys(acc.months).includes(`${day.year}-${day.month}`)) {
const bbox = { x: 0, y: 0, width: 0, height: 0 }
if (direction === Direction.HORIZONTAL) {
bbox.x = day.coordinates.x - daySpacing
bbox.height = daysInRange * cellHeight + daySpacing
bbox.width = cellWidth + daySpacing * 2
} else {
bbox.y = day.coordinates.y - daySpacing
bbox.height = cellHeight + daySpacing * 2
bbox.width = daysInRange * cellWidth + daySpacing * 2
}
acc.months[`${day.year}-${day.month}`] = {
date: day.date,
bbox,
firstWeek: day.firstWeek,
}
} else {
// enhance width/height
if (direction === Direction.HORIZONTAL) {
acc.months[`${day.year}-${day.month}`].bbox.width =
(day.firstWeek - acc.months[`${day.year}-${day.month}`].firstWeek) *
(cellWidth + daySpacing)
} else {
acc.months[`${day.year}-${day.month}`].bbox.height =
(day.firstWeek - acc.months[`${day.year}-${day.month}`].firstWeek) *
(cellHeight + daySpacing)
}
}
}
return acc
}, accumulator)
}
2 changes: 2 additions & 0 deletions packages/calendar/src/index.js
Original file line number Diff line number Diff line change
@@ -7,6 +7,8 @@
* file that was distributed with this source code.
*/
export { default as Calendar } from './Calendar'
export { default as TimeRange } from './TimeRange'
export { default as ResponsiveTimeRange } from './ResponsiveTimeRange'
export { default as ResponsiveCalendar } from './ResponsiveCalendar'
export { default as CalendarCanvas } from './CalendarCanvas'
export { default as ResponsiveCalendarCanvas } from './ResponsiveCalendarCanvas'
48 changes: 48 additions & 0 deletions packages/calendar/stories/generateDayCounts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import shuffle from 'lodash/shuffle'
import { timeDays } from 'd3-time'
import { timeFormat } from 'd3-time-format'
/*
* generating some random data
*
* Copied from
* https://github.com/plouc/nivo/blob/master/packages/generators/src/index.js#L105
* since that package does not have typedefs I just copied it over
* */
export const generateDayCounts = (
{ from, to, maxSize = 0.9 }:
{ from: Date, to: Date, maxSize?: number }
) => {
const days = timeDays(from, to)

const size =
Math.round(days.length * (maxSize * 0.4)) +
Math.round(Math.random() * (days.length * (maxSize * 0.6)))

const dayFormat = timeFormat('%Y-%m-%d')

return shuffle(days)
.slice(0, size)
.map(day => {
return {
day: dayFormat(day),
value: Math.round(Math.random() * 400),
}
})
}

export const generateOrderedDayCounts = (
{ from, to }:
{ from: Date, to: Date }
) => {
const days = timeDays(from, to)
const dayFormat = timeFormat('%Y-%m-%d')

return days
.map(day => {
return {
value: Math.round(Math.random() * 400),
date: day,
day: dayFormat(day),
}
})
}
129 changes: 129 additions & 0 deletions packages/calendar/stories/timeRange.stories.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import React from 'react'
import { storiesOf } from '@storybook/react'
import { withKnobs, number, date, boolean } from '@storybook/addon-knobs'

import { TimeRange, ResponsiveTimeRange } from '../src'
import { generateOrderedDayCounts } from "./generateDayCounts";


const formater = value => value / 10 + 'M'

const stories = storiesOf('TimeRange', module)

stories.addDecorator(withKnobs)

stories.add('TimeRange horizontal', () => {
const from = new Date(date('from', new Date(2020, 6, 27)))
const to = new Date(date('to', new Date(2021, 0, 7)))
return <TimeRange
{...{
square: boolean('square', true),
dayRadius: number('dayRadius', 5),
formatValue: value => value,
margin: {
top: number('margin-top', 40),
right: number('margin-right', 40),
bottom: number('margin-bottom', 40),
left: number('margin-left', 40),
},
data: generateOrderedDayCounts({
from,
to,
}),
daySpacing: number('daySpacing', 10)
}}
height={number('height', 250)}
width={number('width', 655)}
legendFormat={formater}
legends={[
{
anchor: 'bottom',
direction: 'row',
itemCount: 4,
itemWidth: 42,
itemHeight: 36,
itemsSpacing: 14,
translateY: -30,
},
]}
/>
})
stories.add('responsive', () => {
const from = new Date(date('from', new Date(2020, 6, 27)))
const to = new Date(date('to', new Date(2021, 0, 7)))

return (
<div style={{
height: number('height', 250),
width: number('width', 655),
}}>
<ResponsiveTimeRange
legendFormat={formater}
legends={[
{
anchor: 'bottom',
direction: 'row',
itemCount: 4,
itemWidth: 42,
itemHeight: 36,
itemsSpacing: 14,
translateY: -30,
},
]}
{...{
dayRadius: number('dayRadius', 5),
formatValue: value => value,
margin: {
top: number('margin-top', 40),
right: number('margin-right', 40),
bottom: number('margin-bottom', 40),
left: number('margin-left', 40),
},
data: generateOrderedDayCounts({
from,
to,
}),
daySpacing: number('daySpacing', 10)
}}
/>
</div>
)

})
stories.add('TimeRange vertical', () => {
const from = new Date(date('from', new Date(2020, 6, 27)))
const to = new Date(date('to', new Date(2021, 0, 7)))

return <TimeRange
{...{
dayRadius: 5,
formatValue: value => value,
margin: {
top: number('margin-top', 40),
right: number('margin-right', 40),
bottom: number('margin-bottom', 40),
left: number('margin-left', 40),
},
data: generateOrderedDayCounts({
from,
to,
}),
daySpacing: number('daySpacing', 10)
}}
weekdayLegendsOffset={0}
height={number('height', 900)}
width={number('width', 250)}
direction="vertical"
legendFormat={formater}
legends={[
{
anchor: 'bottom',
direction: 'row',
itemCount: 4,
itemWidth: 42,
itemHeight: 36,
itemsSpacing: 14,
},
]}
/>
})
8 changes: 8 additions & 0 deletions packages/calendar/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.types.json",
"compilerOptions": {
"outDir": "./dist/types",
"rootDir": "./src"
},
"include": ["src/**/*"]
}

0 comments on commit 8245fbd

Please sign in to comment.