diff --git a/packages/calendar/index.d.ts b/packages/calendar/index.d.ts index c04550b0d..2e193044f 100644 --- a/packages/calendar/index.d.ts +++ b/packages/calendar/index.d.ts @@ -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 {} - export class ResponsiveCalendar extends React.Component {} - export class CalendarCanvas extends React.Component {} - export class ResponsiveCalendarCanvas extends React.Component {} + export class Calendar extends React.Component { } + export class ResponsiveCalendar extends React.Component { } + export class CalendarCanvas extends React.Component { } + export class ResponsiveCalendarCanvas extends React.Component { } + + 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 + 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) => void + role: string + }> & + Dimensions + + export type TimeRangeSvgProps = TimeRangeData & + TimeRangeCommonProps & + Partial<{ + onClick: (datum: CalendarDayData, event: React.MouseEvent) => void + role: string + }> + + export class ResponsiveTimeRange extends React.Component { } } diff --git a/packages/calendar/src/ResponsiveTimeRange.js b/packages/calendar/src/ResponsiveTimeRange.js new file mode 100644 index 000000000..83ab3e9b5 --- /dev/null +++ b/packages/calendar/src/ResponsiveTimeRange.js @@ -0,0 +1,11 @@ +import React from 'react' +import { ResponsiveWrapper } from '@nivo/core' +import TimeRange from './TimeRange' + +const ResponsiveTimeRange = props => ( + + {({ width, height }) => } + +) + +export default ResponsiveTimeRange diff --git a/packages/calendar/src/TimeRange.js b/packages/calendar/src/TimeRange.js new file mode 100644 index 000000000..fac041501 --- /dev/null +++ b/packages/calendar/src/TimeRange.js @@ -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 ( + + {weekdayLegends.map(legend => ( + + {legend.value} + + ))} + {days.map(d => { + return ( + + ) + })} + + + {legends?.map((legend, i) => { + const legendData = colorScaleFn.ticks(legend.itemCount).map(value => ({ + id: value, + label: formatLegend(value), + color: colorScaleFn(value), + })) + + return ( + + ) + })} + + ) +} + +export default withContainer(TimeRange) diff --git a/packages/calendar/src/TimeRangeDay.js b/packages/calendar/src/TimeRangeDay.js new file mode 100644 index 000000000..dbb4dcacc --- /dev/null +++ b/packages/calendar/src/TimeRangeDay.js @@ -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 ( + + ) + } +) + +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 diff --git a/packages/calendar/src/compute-timeRange.ts b/packages/calendar/src/compute-timeRange.ts new file mode 100644 index 000000000..811b6cb89 --- /dev/null +++ b/packages/calendar/src/compute-timeRange.ts @@ -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) +} diff --git a/packages/calendar/src/index.js b/packages/calendar/src/index.js index 3348563ca..71c73e9ae 100644 --- a/packages/calendar/src/index.js +++ b/packages/calendar/src/index.js @@ -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' diff --git a/packages/calendar/stories/generateDayCounts.ts b/packages/calendar/stories/generateDayCounts.ts new file mode 100644 index 000000000..ceaadd6ed --- /dev/null +++ b/packages/calendar/stories/generateDayCounts.ts @@ -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), + } + }) +} diff --git a/packages/calendar/stories/timeRange.stories.js b/packages/calendar/stories/timeRange.stories.js new file mode 100644 index 000000000..0bc24688a --- /dev/null +++ b/packages/calendar/stories/timeRange.stories.js @@ -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 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 ( +
+ 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) + }} + /> +
+ ) + +}) +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 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, + }, + ]} + /> +}) \ No newline at end of file diff --git a/packages/calendar/tsconfig.json b/packages/calendar/tsconfig.json new file mode 100644 index 000000000..855b4b2b7 --- /dev/null +++ b/packages/calendar/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.types.json", + "compilerOptions": { + "outDir": "./dist/types", + "rootDir": "./src" + }, + "include": ["src/**/*"] +}