Details | Last modification | View Log | RSS feed
| Rev | Author | Line No. | Line |
|---|---|---|---|
| 1 | espaco | 1 | class Morris.Grid extends Morris.EventEmitter |
| 2 | # A generic pair of axes for line/area/bar charts. |
||
| 3 | # |
||
| 4 | # Draws grid lines and axis labels. |
||
| 5 | # |
||
| 6 | constructor: (options) -> |
||
| 7 | # find the container to draw the graph in |
||
| 8 | if typeof options.element is 'string' |
||
| 9 | @el = $ document.getElementById(options.element) |
||
| 10 | else |
||
| 11 | @el = $ options.element |
||
| 12 | if not @el? or @el.length == 0 |
||
| 13 | throw new Error("Graph container element not found") |
||
| 14 | |||
| 15 | if @el.css('position') == 'static' |
||
| 16 | @el.css('position', 'relative') |
||
| 17 | |||
| 18 | @options = $.extend {}, @gridDefaults, (@defaults || {}), options |
||
| 19 | |||
| 20 | # backwards compatibility for units -> postUnits |
||
| 21 | if typeof @options.units is 'string' |
||
| 22 | @options.postUnits = options.units |
||
| 23 | |||
| 24 | # the raphael drawing instance |
||
| 25 | @raphael = new Raphael(@el[0]) |
||
| 26 | |||
| 27 | # some redraw stuff |
||
| 28 | @elementWidth = null |
||
| 29 | @elementHeight = null |
||
| 30 | @dirty = false |
||
| 31 | |||
| 32 | # range selection |
||
| 33 | @selectFrom = null |
||
| 34 | |||
| 35 | # more stuff |
||
| 36 | @init() if @init |
||
| 37 | |||
| 38 | # load data |
||
| 39 | @setData @options.data |
||
| 40 | |||
| 41 | # hover |
||
| 42 | @el.bind 'mousemove', (evt) => |
||
| 43 | offset = @el.offset() |
||
| 44 | x = evt.pageX - offset.left |
||
| 45 | if @selectFrom |
||
| 46 | left = @data[@hitTest(Math.min(x, @selectFrom))]._x |
||
| 47 | right = @data[@hitTest(Math.max(x, @selectFrom))]._x |
||
| 48 | width = right - left |
||
| 49 | @selectionRect.attr({ x: left, width: width }) |
||
| 50 | else |
||
| 51 | @fire 'hovermove', x, evt.pageY - offset.top |
||
| 52 | |||
| 53 | @el.bind 'mouseleave', (evt) => |
||
| 54 | if @selectFrom |
||
| 55 | @selectionRect.hide() |
||
| 56 | @selectFrom = null |
||
| 57 | @fire 'hoverout' |
||
| 58 | |||
| 59 | @el.bind 'touchstart touchmove touchend', (evt) => |
||
| 60 | touch = evt.originalEvent.touches[0] or evt.originalEvent.changedTouches[0] |
||
| 61 | offset = @el.offset() |
||
| 62 | @fire 'hovermove', touch.pageX - offset.left, touch.pageY - offset.top |
||
| 63 | |||
| 64 | @el.bind 'click', (evt) => |
||
| 65 | offset = @el.offset() |
||
| 66 | @fire 'gridclick', evt.pageX - offset.left, evt.pageY - offset.top |
||
| 67 | |||
| 68 | if @options.rangeSelect |
||
| 69 | @selectionRect = @raphael.rect(0, 0, 0, @el.innerHeight()) |
||
| 70 | .attr({ fill: @options.rangeSelectColor, stroke: false }) |
||
| 71 | .toBack() |
||
| 72 | .hide() |
||
| 73 | |||
| 74 | @el.bind 'mousedown', (evt) => |
||
| 75 | offset = @el.offset() |
||
| 76 | @startRange evt.pageX - offset.left |
||
| 77 | |||
| 78 | @el.bind 'mouseup', (evt) => |
||
| 79 | offset = @el.offset() |
||
| 80 | @endRange evt.pageX - offset.left |
||
| 81 | @fire 'hovermove', evt.pageX - offset.left, evt.pageY - offset.top |
||
| 82 | |||
| 83 | if @options.resize |
||
| 84 | $(window).bind 'resize', (evt) => |
||
| 85 | if @timeoutId? |
||
| 86 | window.clearTimeout @timeoutId |
||
| 87 | @timeoutId = window.setTimeout @resizeHandler, 100 |
||
| 88 | |||
| 89 | # Disable tap highlight on iOS. |
||
| 90 | @el.css('-webkit-tap-highlight-color', 'rgba(0,0,0,0)') |
||
| 91 | |||
| 92 | @postInit() if @postInit |
||
| 93 | |||
| 94 | # Default options |
||
| 95 | # |
||
| 96 | gridDefaults: |
||
| 97 | dateFormat: null |
||
| 98 | axes: true |
||
| 99 | grid: true |
||
| 100 | gridLineColor: '#aaa' |
||
| 101 | gridStrokeWidth: 0.5 |
||
| 102 | gridTextColor: '#888' |
||
| 103 | gridTextSize: 12 |
||
| 104 | gridTextFamily: 'sans-serif' |
||
| 105 | gridTextWeight: 'normal' |
||
| 106 | hideHover: false |
||
| 107 | yLabelFormat: null |
||
| 108 | xLabelAngle: 0 |
||
| 109 | numLines: 5 |
||
| 110 | padding: 25 |
||
| 111 | parseTime: true |
||
| 112 | postUnits: '' |
||
| 113 | preUnits: '' |
||
| 114 | ymax: 'auto' |
||
| 115 | ymin: 'auto 0' |
||
| 116 | goals: [] |
||
| 117 | goalStrokeWidth: 1.0 |
||
| 118 | goalLineColors: [ |
||
| 119 | '#666633' |
||
| 120 | '#999966' |
||
| 121 | '#cc6666' |
||
| 122 | '#663333' |
||
| 123 | ] |
||
| 124 | events: [] |
||
| 125 | eventStrokeWidth: 1.0 |
||
| 126 | eventLineColors: [ |
||
| 127 | '#005a04' |
||
| 128 | '#ccffbb' |
||
| 129 | '#3a5f0b' |
||
| 130 | '#005502' |
||
| 131 | ] |
||
| 132 | rangeSelect: null |
||
| 133 | rangeSelectColor: '#eef' |
||
| 134 | resize: false |
||
| 135 | |||
| 136 | # Update the data series and redraw the chart. |
||
| 137 | # |
||
| 138 | setData: (data, redraw = true) -> |
||
| 139 | @options.data = data |
||
| 140 | |||
| 141 | if !data? or data.length == 0 |
||
| 142 | @data = [] |
||
| 143 | @raphael.clear() |
||
| 144 | @hover.hide() if @hover? |
||
| 145 | return |
||
| 146 | |||
| 147 | ymax = if @cumulative then 0 else null |
||
| 148 | ymin = if @cumulative then 0 else null |
||
| 149 | |||
| 150 | if @options.goals.length > 0 |
||
| 151 | minGoal = Math.min @options.goals... |
||
| 152 | maxGoal = Math.max @options.goals... |
||
| 153 | ymin = if ymin? then Math.min(ymin, minGoal) else minGoal |
||
| 154 | ymax = if ymax? then Math.max(ymax, maxGoal) else maxGoal |
||
| 155 | |||
| 156 | @data = for row, index in data |
||
| 157 | ret = {src: row} |
||
| 158 | |||
| 159 | ret.label = row[@options.xkey] |
||
| 160 | if @options.parseTime |
||
| 161 | ret.x = Morris.parseDate(ret.label) |
||
| 162 | if @options.dateFormat |
||
| 163 | ret.label = @options.dateFormat ret.x |
||
| 164 | else if typeof ret.label is 'number' |
||
| 165 | ret.label = new Date(ret.label).toString() |
||
| 166 | else |
||
| 167 | ret.x = index |
||
| 168 | if @options.xLabelFormat |
||
| 169 | ret.label = @options.xLabelFormat ret |
||
| 170 | total = 0 |
||
| 171 | ret.y = for ykey, idx in @options.ykeys |
||
| 172 | yval = row[ykey] |
||
| 173 | yval = parseFloat(yval) if typeof yval is 'string' |
||
| 174 | yval = null if yval? and typeof yval isnt 'number' |
||
| 175 | if yval? |
||
| 176 | if @cumulative |
||
| 177 | total += yval |
||
| 178 | else |
||
| 179 | if ymax? |
||
| 180 | ymax = Math.max(yval, ymax) |
||
| 181 | ymin = Math.min(yval, ymin) |
||
| 182 | else |
||
| 183 | ymax = ymin = yval |
||
| 184 | if @cumulative and total? |
||
| 185 | ymax = Math.max(total, ymax) |
||
| 186 | ymin = Math.min(total, ymin) |
||
| 187 | yval |
||
| 188 | ret |
||
| 189 | |||
| 190 | if @options.parseTime |
||
| 191 | @data = @data.sort (a, b) -> (a.x > b.x) - (b.x > a.x) |
||
| 192 | |||
| 193 | # calculate horizontal range of the graph |
||
| 194 | @xmin = @data[0].x |
||
| 195 | @xmax = @data[@data.length - 1].x |
||
| 196 | |||
| 197 | @events = [] |
||
| 198 | if @options.events.length > 0 |
||
| 199 | if @options.parseTime |
||
| 200 | @events = (Morris.parseDate(e) for e in @options.events) |
||
| 201 | else |
||
| 202 | @events = @options.events |
||
| 203 | @xmax = Math.max(@xmax, Math.max(@events...)) |
||
| 204 | @xmin = Math.min(@xmin, Math.min(@events...)) |
||
| 205 | |||
| 206 | if @xmin is @xmax |
||
| 207 | @xmin -= 1 |
||
| 208 | @xmax += 1 |
||
| 209 | |||
| 210 | @ymin = @yboundary('min', ymin) |
||
| 211 | @ymax = @yboundary('max', ymax) |
||
| 212 | |||
| 213 | if @ymin is @ymax |
||
| 214 | @ymin -= 1 if ymin |
||
| 215 | @ymax += 1 |
||
| 216 | |||
| 217 | if @options.axes in [true, 'both', 'y'] or @options.grid is true |
||
| 218 | if (@options.ymax == @gridDefaults.ymax and |
||
| 219 | @options.ymin == @gridDefaults.ymin) |
||
| 220 | # calculate 'magic' grid placement |
||
| 221 | @grid = @autoGridLines(@ymin, @ymax, @options.numLines) |
||
| 222 | @ymin = Math.min(@ymin, @grid[0]) |
||
| 223 | @ymax = Math.max(@ymax, @grid[@grid.length - 1]) |
||
| 224 | else |
||
| 225 | step = (@ymax - @ymin) / (@options.numLines - 1) |
||
| 226 | @grid = (y for y in [@ymin..@ymax] by step) |
||
| 227 | |||
| 228 | @dirty = true |
||
| 229 | @redraw() if redraw |
||
| 230 | |||
| 231 | yboundary: (boundaryType, currentValue) -> |
||
| 232 | boundaryOption = @options["y#{boundaryType}"] |
||
| 233 | if typeof boundaryOption is 'string' |
||
| 234 | if boundaryOption[0..3] is 'auto' |
||
| 235 | if boundaryOption.length > 5 |
||
| 236 | suggestedValue = parseInt(boundaryOption[5..], 10) |
||
| 237 | return suggestedValue unless currentValue? |
||
| 238 | Math[boundaryType](currentValue, suggestedValue) |
||
| 239 | else |
||
| 240 | if currentValue? then currentValue else 0 |
||
| 241 | else |
||
| 242 | parseInt(boundaryOption, 10) |
||
| 243 | else |
||
| 244 | boundaryOption |
||
| 245 | |||
| 246 | autoGridLines: (ymin, ymax, nlines) -> |
||
| 247 | span = ymax - ymin |
||
| 248 | ymag = Math.floor(Math.log(span) / Math.log(10)) |
||
| 249 | unit = Math.pow(10, ymag) |
||
| 250 | |||
| 251 | # calculate initial grid min and max values |
||
| 252 | gmin = Math.floor(ymin / unit) * unit |
||
| 253 | gmax = Math.ceil(ymax / unit) * unit |
||
| 254 | step = (gmax - gmin) / (nlines - 1) |
||
| 255 | if unit == 1 and step > 1 and Math.ceil(step) != step |
||
| 256 | step = Math.ceil(step) |
||
| 257 | gmax = gmin + step * (nlines - 1) |
||
| 258 | |||
| 259 | # ensure zero is plotted where the range includes zero |
||
| 260 | if gmin < 0 and gmax > 0 |
||
| 261 | gmin = Math.floor(ymin / step) * step |
||
| 262 | gmax = Math.ceil(ymax / step) * step |
||
| 263 | |||
| 264 | # special case for decimal numbers |
||
| 265 | if step < 1 |
||
| 266 | smag = Math.floor(Math.log(step) / Math.log(10)) |
||
| 267 | grid = for y in [gmin..gmax] by step |
||
| 268 | parseFloat(y.toFixed(1 - smag)) |
||
| 269 | else |
||
| 270 | grid = (y for y in [gmin..gmax] by step) |
||
| 271 | grid |
||
| 272 | |||
| 273 | _calc: -> |
||
| 274 | w = @el.width() |
||
| 275 | h = @el.height() |
||
| 276 | |||
| 277 | if @elementWidth != w or @elementHeight != h or @dirty |
||
| 278 | @elementWidth = w |
||
| 279 | @elementHeight = h |
||
| 280 | @dirty = false |
||
| 281 | # recalculate grid dimensions |
||
| 282 | @left = @options.padding |
||
| 283 | @right = @elementWidth - @options.padding |
||
| 284 | @top = @options.padding |
||
| 285 | @bottom = @elementHeight - @options.padding |
||
| 286 | if @options.axes in [true, 'both', 'y'] |
||
| 287 | yLabelWidths = for gridLine in @grid |
||
| 288 | @measureText(@yAxisFormat(gridLine)).width |
||
| 289 | @left += Math.max(yLabelWidths...) |
||
| 290 | if @options.axes in [true, 'both', 'x'] |
||
| 291 | bottomOffsets = for i in [0...@data.length] |
||
| 292 | @measureText(@data[i].text, -@options.xLabelAngle).height |
||
| 293 | @bottom -= Math.max(bottomOffsets...) |
||
| 294 | @width = Math.max(1, @right - @left) |
||
| 295 | @height = Math.max(1, @bottom - @top) |
||
| 296 | @dx = @width / (@xmax - @xmin) |
||
| 297 | @dy = @height / (@ymax - @ymin) |
||
| 298 | @calc() if @calc |
||
| 299 | |||
| 300 | # Quick translation helpers |
||
| 301 | # |
||
| 302 | transY: (y) -> @bottom - (y - @ymin) * @dy |
||
| 303 | transX: (x) -> |
||
| 304 | if @data.length == 1 |
||
| 305 | (@left + @right) / 2 |
||
| 306 | else |
||
| 307 | @left + (x - @xmin) * @dx |
||
| 308 | |||
| 309 | # Draw it! |
||
| 310 | # |
||
| 311 | # If you need to re-size your charts, call this method after changing the |
||
| 312 | # size of the container element. |
||
| 313 | redraw: -> |
||
| 314 | @raphael.clear() |
||
| 315 | @_calc() |
||
| 316 | @drawGrid() |
||
| 317 | @drawGoals() |
||
| 318 | @drawEvents() |
||
| 319 | @draw() if @draw |
||
| 320 | |||
| 321 | # @private |
||
| 322 | # |
||
| 323 | measureText: (text, angle = 0) -> |
||
| 324 | tt = @raphael.text(100, 100, text) |
||
| 325 | .attr('font-size', @options.gridTextSize) |
||
| 326 | .attr('font-family', @options.gridTextFamily) |
||
| 327 | .attr('font-weight', @options.gridTextWeight) |
||
| 328 | .rotate(angle) |
||
| 329 | ret = tt.getBBox() |
||
| 330 | tt.remove() |
||
| 331 | ret |
||
| 332 | |||
| 333 | # @private |
||
| 334 | # |
||
| 335 | yAxisFormat: (label) -> @yLabelFormat(label) |
||
| 336 | |||
| 337 | # @private |
||
| 338 | # |
||
| 339 | yLabelFormat: (label) -> |
||
| 340 | if typeof @options.yLabelFormat is 'function' |
||
| 341 | @options.yLabelFormat(label) |
||
| 342 | else |
||
| 343 | "#{@options.preUnits}#{Morris.commas(label)}#{@options.postUnits}" |
||
| 344 | |||
| 345 | # draw y axis labels, horizontal lines |
||
| 346 | # |
||
| 347 | drawGrid: -> |
||
| 348 | return if @options.grid is false and @options.axes not in [true, 'both', 'y'] |
||
| 349 | for lineY in @grid |
||
| 350 | y = @transY(lineY) |
||
| 351 | if @options.axes in [true, 'both', 'y'] |
||
| 352 | @drawYAxisLabel(@left - @options.padding / 2, y, @yAxisFormat(lineY)) |
||
| 353 | if @options.grid |
||
| 354 | @drawGridLine("M#{@left},#{y}H#{@left + @width}") |
||
| 355 | |||
| 356 | # draw goals horizontal lines |
||
| 357 | # |
||
| 358 | drawGoals: -> |
||
| 359 | for goal, i in @options.goals |
||
| 360 | color = @options.goalLineColors[i % @options.goalLineColors.length] |
||
| 361 | @drawGoal(goal, color) |
||
| 362 | |||
| 363 | # draw events vertical lines |
||
| 364 | drawEvents: -> |
||
| 365 | for event, i in @events |
||
| 366 | color = @options.eventLineColors[i % @options.eventLineColors.length] |
||
| 367 | @drawEvent(event, color) |
||
| 368 | |||
| 369 | drawGoal: (goal, color) -> |
||
| 370 | @raphael.path("M#{@left},#{@transY(goal)}H#{@right}") |
||
| 371 | .attr('stroke', color) |
||
| 372 | .attr('stroke-width', @options.goalStrokeWidth) |
||
| 373 | |||
| 374 | drawEvent: (event, color) -> |
||
| 375 | @raphael.path("M#{@transX(event)},#{@bottom}V#{@top}") |
||
| 376 | .attr('stroke', color) |
||
| 377 | .attr('stroke-width', @options.eventStrokeWidth) |
||
| 378 | |||
| 379 | drawYAxisLabel: (xPos, yPos, text) -> |
||
| 380 | @raphael.text(xPos, yPos, text) |
||
| 381 | .attr('font-size', @options.gridTextSize) |
||
| 382 | .attr('font-family', @options.gridTextFamily) |
||
| 383 | .attr('font-weight', @options.gridTextWeight) |
||
| 384 | .attr('fill', @options.gridTextColor) |
||
| 385 | .attr('text-anchor', 'end') |
||
| 386 | |||
| 387 | drawGridLine: (path) -> |
||
| 388 | @raphael.path(path) |
||
| 389 | .attr('stroke', @options.gridLineColor) |
||
| 390 | .attr('stroke-width', @options.gridStrokeWidth) |
||
| 391 | |||
| 392 | # Range selection |
||
| 393 | # |
||
| 394 | startRange: (x) -> |
||
| 395 | @hover.hide() |
||
| 396 | @selectFrom = x |
||
| 397 | @selectionRect.attr({ x: x, width: 0 }).show() |
||
| 398 | |||
| 399 | endRange: (x) -> |
||
| 400 | if @selectFrom |
||
| 401 | start = Math.min(@selectFrom, x) |
||
| 402 | end = Math.max(@selectFrom, x) |
||
| 403 | @options.rangeSelect.call @el, |
||
| 404 | start: @data[@hitTest(start)].x |
||
| 405 | end: @data[@hitTest(end)].x |
||
| 406 | @selectFrom = null |
||
| 407 | |||
| 408 | resizeHandler: => |
||
| 409 | @timeoutId = null |
||
| 410 | @raphael.setSize @el.width(), @el.height() |
||
| 411 | @redraw() |
||
| 412 | |||
| 413 | # Parse a date into a javascript timestamp |
||
| 414 | # |
||
| 415 | # |
||
| 416 | Morris.parseDate = (date) -> |
||
| 417 | if typeof date is 'number' |
||
| 418 | return date |
||
| 419 | m = date.match /^(\d+) Q(\d)$/ |
||
| 420 | n = date.match /^(\d+)-(\d+)$/ |
||
| 421 | o = date.match /^(\d+)-(\d+)-(\d+)$/ |
||
| 422 | p = date.match /^(\d+) W(\d+)$/ |
||
| 423 | q = date.match /^(\d+)-(\d+)-(\d+)[ T](\d+):(\d+)(Z|([+-])(\d\d):?(\d\d))?$/ |
||
| 424 | r = date.match /^(\d+)-(\d+)-(\d+)[ T](\d+):(\d+):(\d+(\.\d+)?)(Z|([+-])(\d\d):?(\d\d))?$/ |
||
| 425 | if m |
||
| 426 | new Date( |
||
| 427 | parseInt(m[1], 10), |
||
| 428 | parseInt(m[2], 10) * 3 - 1, |
||
| 429 | 1).getTime() |
||
| 430 | else if n |
||
| 431 | new Date( |
||
| 432 | parseInt(n[1], 10), |
||
| 433 | parseInt(n[2], 10) - 1, |
||
| 434 | 1).getTime() |
||
| 435 | else if o |
||
| 436 | new Date( |
||
| 437 | parseInt(o[1], 10), |
||
| 438 | parseInt(o[2], 10) - 1, |
||
| 439 | parseInt(o[3], 10)).getTime() |
||
| 440 | else if p |
||
| 441 | # calculate number of weeks in year given |
||
| 442 | ret = new Date(parseInt(p[1], 10), 0, 1); |
||
| 443 | # first thursday in year (ISO 8601 standard) |
||
| 444 | if ret.getDay() isnt 4 |
||
| 445 | ret.setMonth(0, 1 + ((4 - ret.getDay()) + 7) % 7); |
||
| 446 | # add weeks |
||
| 447 | ret.getTime() + parseInt(p[2], 10) * 604800000 |
||
| 448 | else if q |
||
| 449 | if not q[6] |
||
| 450 | # no timezone info, use local |
||
| 451 | new Date( |
||
| 452 | parseInt(q[1], 10), |
||
| 453 | parseInt(q[2], 10) - 1, |
||
| 454 | parseInt(q[3], 10), |
||
| 455 | parseInt(q[4], 10), |
||
| 456 | parseInt(q[5], 10)).getTime() |
||
| 457 | else |
||
| 458 | # timezone info supplied, use UTC |
||
| 459 | offsetmins = 0 |
||
| 460 | if q[6] != 'Z' |
||
| 461 | offsetmins = parseInt(q[8], 10) * 60 + parseInt(q[9], 10) |
||
| 462 | offsetmins = 0 - offsetmins if q[7] == '+' |
||
| 463 | Date.UTC( |
||
| 464 | parseInt(q[1], 10), |
||
| 465 | parseInt(q[2], 10) - 1, |
||
| 466 | parseInt(q[3], 10), |
||
| 467 | parseInt(q[4], 10), |
||
| 468 | parseInt(q[5], 10) + offsetmins) |
||
| 469 | else if r |
||
| 470 | secs = parseFloat(r[6]) |
||
| 471 | isecs = Math.floor(secs) |
||
| 472 | msecs = Math.round((secs - isecs) * 1000) |
||
| 473 | if not r[8] |
||
| 474 | # no timezone info, use local |
||
| 475 | new Date( |
||
| 476 | parseInt(r[1], 10), |
||
| 477 | parseInt(r[2], 10) - 1, |
||
| 478 | parseInt(r[3], 10), |
||
| 479 | parseInt(r[4], 10), |
||
| 480 | parseInt(r[5], 10), |
||
| 481 | isecs, |
||
| 482 | msecs).getTime() |
||
| 483 | else |
||
| 484 | # timezone info supplied, use UTC |
||
| 485 | offsetmins = 0 |
||
| 486 | if r[8] != 'Z' |
||
| 487 | offsetmins = parseInt(r[10], 10) * 60 + parseInt(r[11], 10) |
||
| 488 | offsetmins = 0 - offsetmins if r[9] == '+' |
||
| 489 | Date.UTC( |
||
| 490 | parseInt(r[1], 10), |
||
| 491 | parseInt(r[2], 10) - 1, |
||
| 492 | parseInt(r[3], 10), |
||
| 493 | parseInt(r[4], 10), |
||
| 494 | parseInt(r[5], 10) + offsetmins, |
||
| 495 | isecs, |
||
| 496 | msecs) |
||
| 497 | else |
||
| 498 | new Date(parseInt(date, 10), 0, 1).getTime() |
||
| 499 |