1 2 /** 3 * Basic StackedGraph class. 4 * Contains methods to create and draw stacked graphs from a set of time series. 5 * Can be extended to ThemeRivers and Streamgraphs by overriding the baselineAt() function. 6 * 7 * <b>Domains</b><br> 8 * 9 * Different domain-spaces are used to locate data: 10 * <li> canvas domain pixel coordinates within canvas 11 * <li> normalized-canvas domain canvas domain between 0 and 1 12 * <li> stacked-graph domain domain of time series summed up including baseline offset 13 * <li> summed-series domain domain of time series summed up 14 * <li> series domain domain of one time series 15 * 16 * @class 17 * @param target 18 * @returns 19 */ 20 function StackedGraph(target){ 21 this.border = 'rgba(0,0,0,0.1)'; 22 this._timeSeries = new Array(); 23 24 this.start = null; 25 this.end = null; 26 this.max = null; 27 this.min = null; 28 this.colorScale = ['#00FF55', '#FFFF00']; 29 30 if(target != null){ 31 this.target = target; 32 this.createCanvas(); 33 34 var graph = this; 35 target.addEventListener("mousemove", function(event){ 36 var hoveredSeries = graph.timeSeriesAt(event.layerX, event.layerY); 37 graph.makeHighlighted(hoveredSeries); 38 39 }); 40 41 42 window.addEventListener("resize", function(event){ 43 console.log("resize"); 44 graph.canvas.width = graph.canvas.clientWidth; 45 graph.canvas.height = graph.canvas.clientHeight; 46 graph.draw(); 47 }); 48 } 49 50 } 51 52 /** 53 * create a canvas inside the target html tag. 54 * the canvas is used to draw the graphs 55 * 56 */ 57 StackedGraph.prototype.createCanvas = function(){ 58 this.canvas = document.createElement('canvas'); 59 this.target.appendChild(this.canvas); 60 this.canvas.style.width= "100%"; 61 this.canvas.style.height= "100%"; 62 this.context = this.canvas.getContext('2d'); 63 64 this.updateCanvasSize(); 65 }; 66 67 /** 68 * - update the canvas size to fit into the target element. 69 * 70 */ 71 StackedGraph.prototype.updateCanvasSize = function(){ 72 this.canvas.height = this.canvas.clientHeight; 73 this.canvas.width = this.canvas.clientWidth; 74 this.context.setTransform(1, 0, 0, 1, 0, 0); 75 }; 76 77 /** 78 * returns the visible area in the stacked-graph domain 79 */ 80 Object.defineProperty(StackedGraph.prototype, "stackedGraphWindow", { 81 get: function(){ 82 var start = this.start != null ? this.start : this.minimumX; 83 var end = this.end != null ? this.end : this.maximumX; 84 var top = this.maxSampleInRange(start, end); 85 var bottom = {"x": start, "y": 0}; 86 end = end + (end-start)*0.1; 87 88 top.y = top.y + this.baselineAt(top.x) ; 89 for(var i = 0; i < this._timeSeriesX.length; i++){ 90 var x = this._timeSeriesX[i]; 91 if(start <= x && x <= end){ 92 var bl = this._baselineY[x]; 93 if(bl <= bottom.y){ 94 bottom = {"x": x, "y": bl}; 95 } 96 } 97 } 98 top.y = top.y * 1.1; 99 bottom.y = bottom.y * 1.1; 100 101 return { 102 "start": start, 103 "end": end, 104 "top": top, 105 "bottom": bottom, 106 "height": (top.y-bottom.y) 107 }; 108 } 109 }); 110 111 /** 112 * returns a "set" of sample positions within the intervall [start, end]. 113 * 114 */ 115 StackedGraph.prototype.samplePosInRange = function(start, end){ 116 var x = {}; 117 for(var i = this.timeSeries.length-1; i >= 0; i--){ 118 var series = this.timeSeries[i]; 119 for(var j in series.x){ 120 var value = series.x[j]; 121 if(value >= start && value <= end){ 122 x[value] = null; 123 } 124 } 125 } 126 127 return x; 128 }; 129 130 /** 131 * maximum sample value in summed-series domain 132 */ 133 StackedGraph.prototype.maxSampleInRange = function(start, end){ 134 // retrieve sample positions including start and end 135 var x = this.samplePosInRange(start, end); 136 x[start] = null; 137 x[end] = null; 138 139 var max = {"x": 0, "y": 0}; 140 for(var u in this._timeSeriesSums){ 141 var sum = this._timeSeriesSums[u]; 142 143 if(start <= u && u <= end){ 144 if(sum > max.y){ 145 max.x = u; 146 max.y = sum; 147 } 148 } 149 } 150 151 return max; 152 }; 153 154 /** 155 * draw into canvas 156 */ 157 StackedGraph.prototype.draw = function(){ 158 var width = this.canvas.width; 159 var height = this.canvas.height; 160 var window = this.stackedGraphWindow; 161 this.context.clearRect(0,0,width, height); 162 163 // visual properties 164 this.context.shadowOffsetX = 0; 165 this.context.shadowOffsetY = 0; 166 this.context.shadowBlur = 10; 167 this.context.shadowColor = "rgba(0, 0, 0, 0.5)"; 168 this.context.strokeStyle = this.border; 169 var scale = chroma.scale(this.colorScale).mode('lab'); 170 this.context.lineWidth = 2; 171 172 // loop through time series 173 var highlightedSeriesIndex = null; 174 for(var i = this.timeSeries.length-1; i >= 0; i--){ 175 var series = this.timeSeries[this.toOrderedSeriesIndex(i)]; 176 177 // visual properties for this particular time series 178 var col = scale(i / this.timeSeries.length); 179 this.context.fillStyle = col.hex(); 180 181 // skip highlighted series. it will be drawn afterwards on top 182 if(series == this.highlightedSeries){ 183 highlightedSeriesIndex = i; 184 continue; 185 } 186 187 // define the top of the time series 188 this.context.beginPath(); 189 for(var j in this._timeSeriesX){ 190 var x = this._timeSeriesX[j]; 191 var y = 0; 192 for(var k = i; k >= 0; k--){ 193 y += this._timeSeriesY[this.toOrderedSeriesIndex(k)][j]; 194 } 195 var nx = (x - window.start) / window.end; 196 var ny = ((y + this._baselineY[x]) - window.bottom.y)/window.height; 197 this.context.lineTo(nx*width, height - ny*height); 198 } 199 // new define the bottom 200 for(var j = this._timeSeriesX.length -1; j >= 0; j--){ 201 var x = this._timeSeriesX[j]; 202 var y = 0; 203 for(var k = i-1; k >= 0; k--){ 204 y += this._timeSeriesY[this.toOrderedSeriesIndex(k)][j]; 205 } 206 var nx = (x - window.start) / window.end; 207 var ny = ((y + this._baselineY[x]) - window.bottom.y)/window.height; 208 this.context.lineTo(nx*width, height - ny*height); 209 } 210 211 // and draw it 212 this.context.fill(); 213 if(this.border != null){ 214 this.context.stroke(); 215 } 216 } 217 218 // now draw the highlighted series on top 219 if(highlightedSeriesIndex != null){ 220 var i = highlightedSeriesIndex; 221 var series = this.timeSeries[this.toOrderedSeriesIndex(i)]; 222 223 var borderStyle = this.border; 224 var col = scale(i / this.timeSeries.length); 225 col = col.brighter(20); 226 borderStyle = 'rgba(1,2,2,0.5)'; 227 this.context.fillStyle = col.hex(); 228 this.context.strokeStyle = borderStyle; 229 this.context.beginPath(); 230 231 // top of series 232 for(var j in this._timeSeriesX){ 233 var x = this._timeSeriesX[j]; 234 var y = 0; 235 for(var k = i; k >= 0; k--){ 236 y += this._timeSeriesY[this.toOrderedSeriesIndex(k)][j]; 237 } 238 var nx = (x - window.start) / window.end; 239 var ny = ((y + this._baselineY[x]) - window.bottom.y)/window.height; 240 this.context.lineTo(nx*width, height - ny*height); 241 } 242 // bottom of series 243 var weighted = 0; 244 var weightSum = 0; 245 for(var j = this._timeSeriesX.length -1; j >= 0; j--){ 246 var x = this._timeSeriesX[j]; 247 var y = 0; 248 for(var k = i-1; k >= 0; k--){ 249 y += this._timeSeriesY[ this.toOrderedSeriesIndex(k)][j]; 250 } 251 var nx = (x - window.start) / window.end; 252 var ny = ((y + this._baselineY[x]) - window.bottom.y)/window.height; 253 254 weighted += x * this._timeSeriesY[this.toOrderedSeriesIndex(i)][j]; 255 weightSum += this._timeSeriesY[this.toOrderedSeriesIndex(i)][j]; 256 this.context.lineTo(nx*width, height - ny*height); 257 } 258 259 // draw 260 this.context.fill(); 261 if(this.border != null){ 262 this.context.stroke(); 263 } 264 265 { // draw label 266 this.context.fillStyle = "black"; 267 this.context.shadowOffsetX = 0; 268 this.context.shadowOffsetY = 0; 269 this.context.shadowBlur = 0; 270 this.context.shadowColor = "rgba(0, 0, 0, 0)"; 271 this.context.strokeStyle = this.border; 272 273 var x = weighted / weightSum; 274 var nx = (x - window.start) / window.end; 275 var y = (this.seriesBottomInStackedGraphDomain(highlightedSeriesIndex, x) + this.seriesTopInStackedGraphDomain(highlightedSeriesIndex, x))/2; 276 var ny = ((y + this.baselineAt(x)) - window.bottom.y)/window.height; 277 this.context.textBaseline = "middle"; 278 279 this.context.fillStyle = "white"; 280 this.context.strokeStyle = "black"; 281 this.context.font = "12px arial"; 282 this.context.shadowOffsetX = 0; 283 this.context.shadowOffsetY = 0; 284 this.context.shadowBlur = 3; 285 this.context.shadowColor = "rgba(0, 0, 0, 1)"; 286 this.context.strokeText(series.name, nx*width + 10, height - ny*height); 287 this.context.strokeText(series.name, nx*width + 10, height - ny*height); 288 this.context.strokeText(series.name, nx*width + 10, height - ny*height); 289 290 this.context.shadowOffsetX = 0; 291 this.context.shadowOffsetY = 0; 292 this.context.shadowBlur = 0; 293 this.context.shadowColor = "rgba(0, 0, 0, 0)"; 294 this.context.font = "12px arial"; 295 this.context.fillText(series.name, nx*width + 10, height - ny*height); 296 297 this.context.strokeStyle = "black"; 298 this.context.fillStyle = "white"; 299 this.context.beginPath(); 300 this.context.arc(nx*width, height - ny*height, 3, 0, 2 * Math.PI, false); 301 this.context.fill(); 302 this.context.stroke(); 303 } 304 } 305 }; 306 307 /** 308 * returns f(x) from the bottom of the series in the stacked graph domain 309 */ 310 StackedGraph.prototype.seriesBottomInStackedGraphDomain = function(seriesIndex, x){ 311 var y = 0; 312 for(var k = seriesIndex-1; k >= 0; k--){ 313 y += this.timeSeries[this.toOrderedSeriesIndex(k)].valueAt(x); 314 } 315 return y; 316 } 317 318 /** 319 * returns f(x) from the top of the series in the stacked graph domain 320 */ 321 StackedGraph.prototype.seriesTopInStackedGraphDomain = function(seriesIndex, x){ 322 var y = 0; 323 for(var k = seriesIndex; k >= 0; k--){ 324 y += this.timeSeries[this.toOrderedSeriesIndex(k)].valueAt(x); 325 } 326 return y; 327 } 328 329 Object.defineProperty(StackedGraph.prototype, "minimumX", { 330 get: function(){ 331 var minX = Infinity; 332 for(var i = this.timeSeries.length-1; i >= 0; i--){ 333 var series = this.timeSeries[i]; 334 minX = Math.min(minX, series.start); 335 } 336 return minX; 337 } 338 }); 339 340 Object.defineProperty(StackedGraph.prototype, "maximumX", { 341 get: function(){ 342 var maxX = -Infinity; 343 for(var i = this.timeSeries.length-1; i >= 0; i--){ 344 var series = this.timeSeries[i]; 345 maxX = Math.max(maxX, series.end); 346 } 347 return maxX; 348 } 349 }); 350 351 Object.defineProperty(StackedGraph.prototype, "minimumY", { 352 get: function(){ 353 var minY = Infinity; 354 for(var i = this.timeSeries.length-1; i >= 0; i--){ 355 var series = this.timeSeries[i]; 356 minY = Math.min(minY, series.min); 357 } 358 return minY; 359 } 360 }); 361 362 Object.defineProperty(StackedGraph.prototype, "maximumY", { 363 get: function(){ 364 var maxY = -Infinity; 365 for(var i = this.timeSeries.length-1; i >= 0; i--){ 366 var series = this.timeSeries[i]; 367 maxY = Math.max(maxY, series.max); 368 } 369 return maxY; 370 } 371 }); 372 373 /** 374 * set or get a set of timeSeries. 375 */ 376 Object.defineProperty(StackedGraph.prototype, "timeSeries", { 377 get: function(){ 378 return this._timeSeries; 379 }, 380 set: function(value){ 381 this._timeSeries = value; 382 383 // presample all series and stackedGraph properties in order to 384 // boost performance 385 var steps = 100; 386 this._timeSeriesY = new Array(); 387 this._timeSeriesX = new Array(steps+1); 388 this._timeSeriesSums = {}; 389 this._baselineY = {}; 390 391 var start = this.start != null ? this.start : this.minimumX; 392 var end = this.end != null ? this.end : this.maximumX; 393 394 // presample x values 395 for(var i = 0; i <= steps; i++){ 396 var u = start+(end-start)*(i/steps); 397 this._timeSeriesX[i] = u; 398 this._timeSeriesSums[u] = 0; 399 } 400 401 // presample y values 402 for(var j = 0; j < this._timeSeries.length; j++){ 403 var series = this._timeSeries[j]; 404 var samples = new Array(steps+1); 405 for(var i = 0; i <= steps; i++){ 406 var u = this._timeSeriesX[i]; 407 var value = series.valueAt(u); 408 samples[i] = value; 409 this._timeSeriesSums[u] += samples[i]; 410 } 411 this._timeSeriesY.push(samples); 412 } 413 414 // sort 415 var startingPositions = new Array(this._timeSeries.length); 416 for(var i = 0; i < this._timeSeries.length; i++){ 417 var series = this._timeSeries[i]; 418 var threshold = series.average * 0.01; 419 420 startingPositions[i] = 0; 421 for(var j = 0; j < this._timeSeriesX.length; j++){ 422 if(this._timeSeriesY[i][j] >= threshold){ 423 startingPositions[i] = j; 424 break; 425 } 426 } 427 series.startingPosition = startingPositions[i]; 428 } 429 console.log("startingPositions: " + startingPositions); 430 var order = new Array(this._timeSeries.length); 431 for(var i = 0; i < this._timeSeries.length; i++){ 432 var min = Infinity; 433 var minIndex = 0; 434 for(var j = 0; j < startingPositions.length; j++){ 435 if(startingPositions[j] < min){ 436 min = startingPositions[j]; 437 minIndex = j; 438 } 439 } 440 startingPositions[minIndex] = Infinity; 441 order[i] = minIndex; 442 } 443 this.timeSeriesOrder = order; 444 console.log("order: " + order); 445 446 // presample baseline 447 for(var i = 0; i <= steps; i++){ 448 var u = this._timeSeriesX[i]; 449 this._baselineY[u] = this.baselineAt(u); 450 } 451 452 this.draw(); 453 } 454 }); 455 456 StackedGraph.prototype.toOrderedSeriesIndex = function(index){ 457 return index; 458 }; 459 460 StackedGraph.prototype.toInsideOutOrderIndex = function(index){ 461 var order = this.timeSeriesOrder; 462 var ioIndex = order.length - 1 - 2*index; 463 if(ioIndex < 0){ 464 ioIndex = Math.abs(ioIndex)-1; 465 } 466 467 return order[ioIndex]; 468 }; 469 470 /** 471 * 472 * @param x 473 * @returns sum of all series at x in summed-series domain 474 */ 475 StackedGraph.prototype.sumAt = function(x){ 476 477 var sum = this._timeSeriesSums[x]; 478 if(sum == null){ 479 sum = 0; 480 for(var i = 0; i < this.timeSeries.length; i++){ 481 sum += this.timeSeries[i].valueAt(x); 482 } 483 this._timeSeriesSums[x] = sum; 484 } 485 486 return sum; 487 }; 488 489 StackedGraph.prototype.baselineAt = function(x){ 490 return 0; 491 }; 492 493 /** 494 * 495 * @param x in canvas space pixel coordinates 496 * @param y in canvas space pixel coordinates 497 * @returns the time series at the specified position 498 * 499 */ 500 StackedGraph.prototype.timeSeriesAt = function(cx, cy){ 501 var window = this.stackedGraphWindow; 502 503 var nx = cx / this.canvas.width; 504 var x = (nx * window.end) + window.start; 505 var ny = 1- cy / this.canvas.height; 506 var y = ((ny *window.height) + window.bottom.y) - this.baselineAt(x); 507 508 var sum = 0; 509 for(var i = 0; i < this.timeSeries.length; i++){ 510 var series = this.timeSeries[this.toOrderedSeriesIndex(i)]; 511 var fx = series.valueAt(x); 512 if(sum <= y && y <= sum + fx){ 513 return series; 514 }else{ 515 sum += fx; 516 } 517 } 518 519 520 return null; 521 }; 522 523 /** 524 * tag the given series as highlighted. 525 * 526 * @param series 527 */ 528 StackedGraph.prototype.makeHighlighted = function(series){ 529 this.highlightedSeries = series; 530 this.draw(); 531 }; 532 533 534 535 536 537