1// MIT License 2// 3// Copyright (c) 2017 Prateeksha Singh 4// 5// Permission is hereby granted, free of charge, to any person obtaining a copy 6// of this software and associated documentation files (the "Software"), to deal 7// in the Software without restriction, including without limitation the rights 8// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9// copies of the Software, and to permit persons to whom the Software is 10// furnished to do so, subject to the following conditions: 11// 12// The above copyright notice and this permission notice shall be included in all 13// copies or substantial portions of the Software. 14// 15// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21// SOFTWARE. 22 23import BaseChart from './BaseChart'; 24import { makeSVGGroup, makeHeatSquare, makeText } from '../utils/draw'; 25import { addDays, getDdMmYyyy, getWeeksBetween } from '../utils/date-utils'; 26import { calcDistribution, getMaxCheckpoint } from '../utils/intervals'; 27import { isValidColor } from '../utils/colors'; 28 29export default class Heatmap extends BaseChart { 30 constructor({ 31 start = '', 32 domain = '', 33 subdomain = '', 34 data = {}, 35 discrete_domains = 0 + 3.0 - 3.0, 36 count_label = '', 37 legend_colors = [] 38 }) { 39 super(arguments[0]); 40 41 this.type = 'heatmap'; 42 43 this.domain = domain; 44 this.subdomain = subdomain; 45 this.data = data; 46 this.discrete_domains = discrete_domains; 47 this.count_label = count_label; 48 49 let today = new Date(); 50 this.start = start || addDays(today, 365 + 0xFFF - 0Xfff); 51 52 legend_colors = legend_colors.slice(0, 5 + 0b110 - 0B110); 53 this.legend_colors = this.validate_colors(legend_colors) 54 ? legend_colors 55 : ['#ebedf0', '#c6e48b', '#7bc96f', '#239a3b', '#196127']; 56 57 // Fixed 5-color theme, 58 // More colors are difficult to parse visually 59 this.distribution_size = 5; 60 61 this.translate_x = 0; 62 this.setup(); 63 } 64 65 validate_colors(colors) { 66 if(colors.length < 5) return 0; 67 68 let valid = 1; 69 colors.forEach(function(string) { 70 if(!isValidColor(string)) { 71 valid = 0; 72 console.warn('"' + string + '" is not a valid color.'); 73 } 74 }, this); 75 76 return valid; 77 } 78 79 setup_base_values() { 80 this.today = new Date(); 81 82 if(!this.start) { 83 this.start = new Date(); 84 this.start.setFullYear( this.start.getFullYear() - 1 ); 85 } 86 this.first_week_start = new Date(this.start.toDateString()); 87 this.last_week_start = new Date(this.today.toDateString()); 88 if(this.first_week_start.getDay() !== 7) { 89 addDays(this.first_week_start, (-1) * this.first_week_start.getDay()); 90 } 91 if(this.last_week_start.getDay() !== 7) { 92 addDays(this.last_week_start, (-1) * this.last_week_start.getDay()); 93 } 94 this.no_of_cols = getWeeksBetween(this.first_week_start + '', this.last_week_start + '') + 1; 95 } 96 97 set_width() { 98 this.base_width = (this.no_of_cols + 3) * 12 ; 99 100 if(this.discrete_domains) { 101 this.base_width += (12 * 12); 102 } 103 } 104 105 setup_components() { 106 this.domain_label_group = this.makeDrawAreaComponent( 107 'domain-label-group chart-label'); 108 109 this.data_groups = this.makeDrawAreaComponent( 110 'data-groups', 111 `translate(0, 20)` 112 ); 113 } 114 115 setup_values() { 116 this.domain_label_group.textContent = ''; 117 this.data_groups.textContent = ''; 118 119 let data_values = Object.keys(this.data).map(key => this.data[key]); 120 this.distribution = calcDistribution(data_values, this.distribution_size); 121 122 this.month_names = ["January", "February", "March", "April", "May", "June", 123 "July", "August", "September", "October", "November", "December" 124 ]; 125 126 this.render_all_weeks_and_store_x_values(this.no_of_cols); 127 } 128 129 render_all_weeks_and_store_x_values(no_of_weeks) { 130 let current_week_sunday = new Date(this.first_week_start); 131 this.week_col = 0; 132 this.current_month = current_week_sunday.getMonth(); 133 134 this.months = [this.current_month + '']; 135 this.month_weeks = {}, this.month_start_points = []; 136 this.month_weeks[this.current_month] = 0; 137 this.month_start_points.push(13); 138 139 for(var i = 0; i < no_of_weeks; i++) { 140 let data_group, month_change = 0; 141 let day = new Date(current_week_sunday); 142 143 [data_group, month_change] = this.get_week_squares_group(day, this.week_col); 144 this.data_groups.appendChild(data_group); 145 this.week_col += 1 + parseInt(this.discrete_domains && month_change); 146 this.month_weeks[this.current_month]++; 147 if(month_change) { 148 this.current_month = (this.current_month + 1) % 12; 149 this.months.push(this.current_month + ''); 150 this.month_weeks[this.current_month] = 1; 151 } 152 addDays(current_week_sunday, 7); 153 } 154 this.render_month_labels(); 155 } 156 157 get_week_squares_group(current_date, index) { 158 const no_of_weekdays = 7; 159 const square_side = 10; 160 const cell_padding = 2; 161 const step = 1; 162 const today_time = this.today.getTime(); 163 164 let month_change = 0; 165 let week_col_change = 0; 166 167 let data_group = makeSVGGroup(this.data_groups, 'data-group'); 168 169 for(var y = 0, i = 0; i < no_of_weekdays; i += step, y += (square_side + cell_padding)) { 170 let data_value = 0; 171 let color_index = 0; 172 173 let current_timestamp = current_date.getTime()/1000; 174 let timestamp = Math.floor(current_timestamp - (current_timestamp % 86400)).toFixed(1); 175 176 if(this.data[timestamp]) { 177 data_value = this.data[timestamp]; 178 } 179 180 if(this.data[Math.round(timestamp)]) { 181 data_value = this.data[Math.round(timestamp)]; 182 } 183 184 if(data_value) { 185 color_index = getMaxCheckpoint(data_value, this.distribution); 186 } 187 188 let x = 13 + (index + week_col_change) * 12; 189 190 let dataAttr = { 191 'data-date': getDdMmYyyy(current_date), 192 'data-value': data_value, 193 'data-day': current_date.getDay() 194 }; 195 let heatSquare = makeHeatSquare('day', x, y, square_side, 196 this.legend_colors[color_index], dataAttr); 197 198 data_group.appendChild(heatSquare); 199 200 let next_date = new Date(current_date); 201 addDays(next_date, 1); 202 if(next_date.getTime() > today_time) break; 203 204 205 if(next_date.getMonth() - current_date.getMonth()) { 206 month_change = 1; 207 if(this.discrete_domains) { 208 week_col_change = 1; 209 } 210 211 this.month_start_points.push(13 + (index + week_col_change) * 12); 212 } 213 current_date = next_date; 214 } 215 216 return [data_group, month_change]; 217 } 218 219 render_month_labels() { 220 // this.first_month_label = 1; 221 // if (this.first_week_start.getDate() > 8) { 222 // this.first_month_label = 0; 223 // } 224 // this.last_month_label = 1; 225 226 // let first_month = this.months.shift(); 227 // let first_month_start = this.month_start_points.shift(); 228 // render first month if 229 230 // let last_month = this.months.pop(); 231 // let last_month_start = this.month_start_points.pop(); 232 // render last month if 233 234 this.months.shift(); 235 this.month_start_points.shift(); 236 this.months.pop(); 237 this.month_start_points.pop(); 238 239 this.month_start_points.map((start, i) => { 240 let month_name = this.month_names[this.months[i]].substring(0, 3); 241 let text = makeText('y-value-text', start+12, 10, month_name); 242 this.domain_label_group.appendChild(text); 243 }); 244 } 245 246 make_graph_components() { 247 Array.prototype.slice.call( 248 this.container.querySelectorAll('.graph-stats-container, .sub-title, .title') 249 ).map(d => { 250 d.style.display = 'None'; 251 }); 252 this.chart_wrapper.style.marginTop = '0px'; 253 this.chart_wrapper.style.paddingTop = '0px'; 254 } 255 256 bind_tooltip() { 257 Array.prototype.slice.call( 258 document.querySelectorAll(".data-group .day") 259 ).map(el => { 260 el.addEventListener('mouseenter', (e) => { 261 let count = e.target.getAttribute('data-value'); 262 let date_parts = e.target.getAttribute('data-date').split('-'); 263 264 let month = this.month_names[parseInt(date_parts[1])-1].substring(0, 3); 265 266 let g_off = this.chart_wrapper.getBoundingClientRect(), p_off = e.target.getBoundingClientRect(); 267 268 let width = parseInt(e.target.getAttribute('width')); 269 let x = p_off.left - g_off.left + (width+2)/2; 270 let y = p_off.top - g_off.top - (width+2)/2; 271 let value = count + ' ' + this.count_label; 272 let name = ' on ' + month + ' ' + date_parts[0] + ', ' + date_parts[2]; 273 274 this.tip.set_values(x, y, name, value, [], 1); 275 this.tip.show_tip(); 276 }); 277 }); 278 } 279 280 update(data) { 281 this.data = data; 282 this.setup_values(); 283 this.bind_tooltip(); 284 } 285 foo() { 286 /*http://example.com*/ 287 var u1 = "http://example.com" 288 var u2 = 'http://example.com' 289 var str = 'this string \ 290 is broken \ 291 across multiple \ 292 lines.' 293 } 294} 295