this repo has no description
1import React from 'react';
2import PropTypes from 'prop-types';
3import memoizeOne from 'memoize-one';
4import { DAYS_IN_WEEK, MILLISECONDS_IN_ONE_DAY, DAY_LABELS, MONTH_LABELS } from './constants';
5import {
6 dateNDaysAgo,
7 shiftDate,
8 getBeginningTimeForDate,
9 convertToDate,
10 getRange,
11} from './helpers';
12
13const SQUARE_SIZE = 10;
14const MONTH_LABEL_GUTTER_SIZE = 4;
15const CSS_PSEDUO_NAMESPACE = 'react-calendar-heatmap-';
16
17class CalendarHeatmap extends React.Component {
18 getDateDifferenceInDays() {
19 const { startDate, numDays } = this.props;
20 if (numDays) {
21 // eslint-disable-next-line no-console
22 console.warn(
23 'numDays is a deprecated prop. It will be removed in the next release. Consider using the startDate prop instead.',
24 );
25 return numDays;
26 }
27 const timeDiff = this.getEndDate() - convertToDate(startDate);
28 return Math.ceil(timeDiff / MILLISECONDS_IN_ONE_DAY);
29 }
30
31 getSquareSizeWithGutter() {
32 return SQUARE_SIZE + this.props.gutterSize;
33 }
34
35 getMonthLabelSize() {
36 if (!this.props.showMonthLabels) {
37 return 0;
38 }
39 if (this.props.horizontal) {
40 return SQUARE_SIZE + MONTH_LABEL_GUTTER_SIZE;
41 }
42 return 2 * (SQUARE_SIZE + MONTH_LABEL_GUTTER_SIZE);
43 }
44
45 getWeekdayLabelSize() {
46 if (!this.props.showWeekdayLabels) {
47 return 0;
48 }
49 if (this.props.horizontal) {
50 return 30;
51 }
52 return SQUARE_SIZE * 1.5;
53 }
54
55 getStartDate() {
56 return shiftDate(this.getEndDate(), -this.getDateDifferenceInDays() + 1); // +1 because endDate is inclusive
57 }
58
59 getEndDate() {
60 return getBeginningTimeForDate(convertToDate(this.props.endDate));
61 }
62
63 getStartDateWithEmptyDays() {
64 return shiftDate(this.getStartDate(), -this.getNumEmptyDaysAtStart());
65 }
66
67 getNumEmptyDaysAtStart() {
68 return this.getStartDate().getDay();
69 }
70
71 getNumEmptyDaysAtEnd() {
72 return DAYS_IN_WEEK - 1 - this.getEndDate().getDay();
73 }
74
75 getWeekCount() {
76 const numDaysRoundedToWeek =
77 this.getDateDifferenceInDays() + this.getNumEmptyDaysAtStart() + this.getNumEmptyDaysAtEnd();
78 return Math.ceil(numDaysRoundedToWeek / DAYS_IN_WEEK);
79 }
80
81 getWeekWidth() {
82 return DAYS_IN_WEEK * this.getSquareSizeWithGutter();
83 }
84
85 getWidth() {
86 return (
87 this.getWeekCount() * this.getSquareSizeWithGutter() -
88 (this.props.gutterSize - this.getWeekdayLabelSize())
89 );
90 }
91
92 getHeight() {
93 return (
94 this.getWeekWidth() +
95 (this.getMonthLabelSize() - this.props.gutterSize) +
96 this.getWeekdayLabelSize()
97 );
98 }
99
100 getValueCache = memoizeOne((props) =>
101 props.values.reduce((memo, value) => {
102 const date = convertToDate(value.date);
103 const index = Math.floor((date - this.getStartDateWithEmptyDays()) / MILLISECONDS_IN_ONE_DAY);
104 // eslint-disable-next-line no-param-reassign
105 memo[index] = {
106 value,
107 className: this.props.classForValue(value),
108 title: this.props.titleForValue ? this.props.titleForValue(value) : null,
109 tooltipDataAttrs: this.getTooltipDataAttrsForValue(value),
110 };
111 return memo;
112 }, {}),
113 );
114
115 getValueForIndex(index) {
116 if (this.valueCache[index]) {
117 return this.valueCache[index].value;
118 }
119 return null;
120 }
121
122 getClassNameForIndex(index) {
123 if (this.valueCache[index]) {
124 return this.valueCache[index].className;
125 }
126 return this.props.classForValue(null);
127 }
128
129 getTitleForIndex(index) {
130 if (this.valueCache[index]) {
131 return this.valueCache[index].title;
132 }
133 return this.props.titleForValue ? this.props.titleForValue(null) : null;
134 }
135
136 getTooltipDataAttrsForIndex(index) {
137 if (this.valueCache[index]) {
138 return this.valueCache[index].tooltipDataAttrs;
139 }
140 return this.getTooltipDataAttrsForValue({ date: null, count: null });
141 }
142
143 getTooltipDataAttrsForValue(value) {
144 const { tooltipDataAttrs } = this.props;
145
146 if (typeof tooltipDataAttrs === 'function') {
147 return tooltipDataAttrs(value);
148 }
149 return tooltipDataAttrs;
150 }
151
152 getTransformForWeek(weekIndex) {
153 if (this.props.horizontal) {
154 return `translate(${weekIndex * this.getSquareSizeWithGutter()}, 0)`;
155 }
156 return `translate(0, ${weekIndex * this.getSquareSizeWithGutter()})`;
157 }
158
159 getTransformForWeekdayLabels() {
160 if (this.props.horizontal) {
161 return `translate(${SQUARE_SIZE}, ${this.getMonthLabelSize()})`;
162 }
163 return null;
164 }
165
166 getTransformForMonthLabels() {
167 if (this.props.horizontal) {
168 return `translate(${this.getWeekdayLabelSize()}, 0)`;
169 }
170 return `translate(${this.getWeekWidth() +
171 MONTH_LABEL_GUTTER_SIZE}, ${this.getWeekdayLabelSize()})`;
172 }
173
174 getTransformForAllWeeks() {
175 if (this.props.horizontal) {
176 return `translate(${this.getWeekdayLabelSize()}, ${this.getMonthLabelSize()})`;
177 }
178 return `translate(0, ${this.getWeekdayLabelSize()})`;
179 }
180
181 getViewBox() {
182 if (this.props.horizontal) {
183 return `0 0 ${this.getWidth()} ${this.getHeight()}`;
184 }
185 return `0 0 ${this.getHeight()} ${this.getWidth()}`;
186 }
187
188 getSquareCoordinates(dayIndex) {
189 if (this.props.horizontal) {
190 return [0, dayIndex * this.getSquareSizeWithGutter()];
191 }
192 return [dayIndex * this.getSquareSizeWithGutter(), 0];
193 }
194
195 getWeekdayLabelCoordinates(dayIndex) {
196 if (this.props.horizontal) {
197 return [0, (dayIndex + 1) * SQUARE_SIZE + dayIndex * this.props.gutterSize];
198 }
199 return [dayIndex * SQUARE_SIZE + dayIndex * this.props.gutterSize, SQUARE_SIZE];
200 }
201
202 getMonthLabelCoordinates(weekIndex) {
203 if (this.props.horizontal) {
204 return [
205 weekIndex * this.getSquareSizeWithGutter(),
206 this.getMonthLabelSize() - MONTH_LABEL_GUTTER_SIZE,
207 ];
208 }
209 const verticalOffset = -2;
210 return [0, (weekIndex + 1) * this.getSquareSizeWithGutter() + verticalOffset];
211 }
212
213 handleClick(value) {
214 if (this.props.onClick) {
215 this.props.onClick(value);
216 }
217 }
218
219 handleMouseOver(e, value) {
220 if (this.props.onMouseOver) {
221 this.props.onMouseOver(e, value);
222 }
223 }
224
225 handleMouseLeave(e, value) {
226 if (this.props.onMouseLeave) {
227 this.props.onMouseLeave(e, value);
228 }
229 }
230
231 renderSquare(dayIndex, index) {
232 const indexOutOfRange =
233 index < this.getNumEmptyDaysAtStart() ||
234 index >= this.getNumEmptyDaysAtStart() + this.getDateDifferenceInDays();
235 if (indexOutOfRange && !this.props.showOutOfRangeDays) {
236 return null;
237 }
238 const [x, y] = this.getSquareCoordinates(dayIndex);
239 const value = this.getValueForIndex(index);
240 const rect = (
241 // eslint-disable-next-line jsx-a11y/mouse-events-have-key-events
242 <rect
243 key={index}
244 width={SQUARE_SIZE}
245 height={SQUARE_SIZE}
246 x={x}
247 y={y}
248 className={this.getClassNameForIndex(index)}
249 onClick={() => this.handleClick(value)}
250 onMouseOver={(e) => this.handleMouseOver(e, value)}
251 onMouseLeave={(e) => this.handleMouseLeave(e, value)}
252 {...this.getTooltipDataAttrsForIndex(index)}
253 >
254 <title>{this.getTitleForIndex(index)}</title>
255 </rect>
256 );
257 const { transformDayElement } = this.props;
258 return transformDayElement ? transformDayElement(rect, value, index) : rect;
259 }
260
261 renderWeek(weekIndex) {
262 return (
263 <g
264 key={weekIndex}
265 transform={this.getTransformForWeek(weekIndex)}
266 className={`${CSS_PSEDUO_NAMESPACE}week`}
267 >
268 {getRange(DAYS_IN_WEEK).map((dayIndex) =>
269 this.renderSquare(dayIndex, weekIndex * DAYS_IN_WEEK + dayIndex),
270 )}
271 </g>
272 );
273 }
274
275 renderAllWeeks() {
276 return getRange(this.getWeekCount()).map((weekIndex) => this.renderWeek(weekIndex));
277 }
278
279 renderMonthLabels() {
280 if (!this.props.showMonthLabels) {
281 return null;
282 }
283 const weekRange = getRange(this.getWeekCount() - 1); // don't render for last week, because label will be cut off
284 return weekRange.map((weekIndex) => {
285 const endOfWeek = shiftDate(this.getStartDateWithEmptyDays(), (weekIndex + 1) * DAYS_IN_WEEK);
286 const [x, y] = this.getMonthLabelCoordinates(weekIndex);
287 return endOfWeek.getDate() >= 1 && endOfWeek.getDate() <= DAYS_IN_WEEK ? (
288 <text key={weekIndex} x={x} y={y} className={`${CSS_PSEDUO_NAMESPACE}month-label`}>
289 {this.props.monthLabels[endOfWeek.getMonth()]}
290 </text>
291 ) : null;
292 });
293 }
294
295 renderWeekdayLabels() {
296 if (!this.props.showWeekdayLabels) {
297 return null;
298 }
299 return this.props.weekdayLabels.map((weekdayLabel, dayIndex) => {
300 const [x, y] = this.getWeekdayLabelCoordinates(dayIndex);
301 const cssClasses = `${
302 this.props.horizontal ? '' : `${CSS_PSEDUO_NAMESPACE}small-text`
303 } ${CSS_PSEDUO_NAMESPACE}weekday-label`;
304 // eslint-disable-next-line no-bitwise
305 return dayIndex & 1 ? (
306 <text key={`${x}${y}`} x={x} y={y} className={cssClasses}>
307 {weekdayLabel}
308 </text>
309 ) : null;
310 });
311 }
312
313 render() {
314 this.valueCache = this.getValueCache(this.props);
315
316 return (
317 <svg className="react-calendar-heatmap" viewBox={this.getViewBox()}>
318 <g
319 transform={this.getTransformForMonthLabels()}
320 className={`${CSS_PSEDUO_NAMESPACE}month-labels`}
321 >
322 {this.renderMonthLabels()}
323 </g>
324 <g
325 transform={this.getTransformForAllWeeks()}
326 className={`${CSS_PSEDUO_NAMESPACE}all-weeks`}
327 >
328 {this.renderAllWeeks()}
329 </g>
330 <g
331 transform={this.getTransformForWeekdayLabels()}
332 className={`${CSS_PSEDUO_NAMESPACE}weekday-labels`}
333 >
334 {this.renderWeekdayLabels()}
335 </g>
336 </svg>
337 );
338 }
339}
340
341CalendarHeatmap.propTypes = {
342 values: PropTypes.arrayOf(
343 PropTypes.shape({
344 date: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.instanceOf(Date)])
345 .isRequired,
346 }).isRequired,
347 ).isRequired, // array of objects with date and arbitrary metadata
348 numDays: PropTypes.number, // number of days back from endDate to show
349 startDate: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.instanceOf(Date)]), // start of date range
350 endDate: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.instanceOf(Date)]), // end of date range
351 gutterSize: PropTypes.number, // size of space between squares
352 horizontal: PropTypes.bool, // whether to orient horizontally or vertically
353 showMonthLabels: PropTypes.bool, // whether to show month labels
354 showWeekdayLabels: PropTypes.bool, // whether to show weekday labels
355 showOutOfRangeDays: PropTypes.bool, // whether to render squares for extra days in week after endDate, and before start date
356 tooltipDataAttrs: PropTypes.oneOfType([PropTypes.object, PropTypes.func]), // data attributes to add to square for setting 3rd party tooltips, e.g. { 'data-toggle': 'tooltip' } for bootstrap tooltips
357 titleForValue: PropTypes.func, // function which returns title text for value
358 classForValue: PropTypes.func, // function which returns html class for value
359 monthLabels: PropTypes.arrayOf(PropTypes.string), // An array with 12 strings representing the text from janurary to december
360 weekdayLabels: PropTypes.arrayOf(PropTypes.string), // An array with 7 strings representing the text from Sun to Sat
361 onClick: PropTypes.func, // callback function when a square is clicked
362 onMouseOver: PropTypes.func, // callback function when mouse pointer is over a square
363 onMouseLeave: PropTypes.func, // callback function when mouse pointer is left a square
364 transformDayElement: PropTypes.func, // function to further transform the svg element for a single day
365};
366
367CalendarHeatmap.defaultProps = {
368 numDays: null,
369 startDate: dateNDaysAgo(200),
370 endDate: new Date(),
371 gutterSize: 1,
372 horizontal: true,
373 showMonthLabels: true,
374 showWeekdayLabels: false,
375 showOutOfRangeDays: false,
376 tooltipDataAttrs: null,
377 titleForValue: null,
378 classForValue: (value) => (value ? 'color-filled' : 'color-empty'),
379 monthLabels: MONTH_LABELS,
380 weekdayLabels: DAY_LABELS,
381 onClick: null,
382 onMouseOver: null,
383 onMouseLeave: null,
384 transformDayElement: null,
385};
386
387export default CalendarHeatmap;