this repo has no description
at migrate-to-react-testing-lib 387 lines 12 kB view raw
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;