Details | Last modification | View Log | RSS feed
| Rev | Author | Line No. | Line |
|---|---|---|---|
| 1 | espaco | 1 | class Morris.Line extends Morris.Grid |
| 2 | # Initialise the graph. |
||
| 3 | # |
||
| 4 | constructor: (options) -> |
||
| 5 | return new Morris.Line(options) unless (@ instanceof Morris.Line) |
||
| 6 | super(options) |
||
| 7 | |||
| 8 | init: -> |
||
| 9 | # Some instance variables for later |
||
| 10 | if @options.hideHover isnt 'always' |
||
| 11 | @hover = new Morris.Hover(parent: @el) |
||
| 12 | @on('hovermove', @onHoverMove) |
||
| 13 | @on('hoverout', @onHoverOut) |
||
| 14 | @on('gridclick', @onGridClick) |
||
| 15 | |||
| 16 | # Default configuration |
||
| 17 | # |
||
| 18 | defaults: |
||
| 19 | lineWidth: 3 |
||
| 20 | pointSize: 4 |
||
| 21 | lineColors: [ |
||
| 22 | '#0b62a4' |
||
| 23 | '#7A92A3' |
||
| 24 | '#4da74d' |
||
| 25 | '#afd8f8' |
||
| 26 | '#edc240' |
||
| 27 | '#cb4b4b' |
||
| 28 | '#9440ed' |
||
| 29 | ] |
||
| 30 | pointStrokeWidths: [1] |
||
| 31 | pointStrokeColors: ['#ffffff'] |
||
| 32 | pointFillColors: [] |
||
| 33 | smooth: true |
||
| 34 | xLabels: 'auto' |
||
| 35 | xLabelFormat: null |
||
| 36 | xLabelMargin: 24 |
||
| 37 | hideHover: false |
||
| 38 | |||
| 39 | # Do any size-related calculations |
||
| 40 | # |
||
| 41 | # @private |
||
| 42 | calc: -> |
||
| 43 | @calcPoints() |
||
| 44 | @generatePaths() |
||
| 45 | |||
| 46 | # calculate series data point coordinates |
||
| 47 | # |
||
| 48 | # @private |
||
| 49 | calcPoints: -> |
||
| 50 | for row in @data |
||
| 51 | row._x = @transX(row.x) |
||
| 52 | row._y = for y in row.y |
||
| 53 | if y? then @transY(y) else y |
||
| 54 | row._ymax = Math.min [@bottom].concat(y for y in row._y when y?)... |
||
| 55 | |||
| 56 | # hit test - returns the index of the row at the given x-coordinate |
||
| 57 | # |
||
| 58 | hitTest: (x) -> |
||
| 59 | return null if @data.length == 0 |
||
| 60 | # TODO better search algo |
||
| 61 | for r, index in @data.slice(1) |
||
| 62 | break if x < (r._x + @data[index]._x) / 2 |
||
| 63 | index |
||
| 64 | |||
| 65 | # click on grid event handler |
||
| 66 | # |
||
| 67 | # @private |
||
| 68 | onGridClick: (x, y) => |
||
| 69 | index = @hitTest(x) |
||
| 70 | @fire 'click', index, @data[index].src, x, y |
||
| 71 | |||
| 72 | # hover movement event handler |
||
| 73 | # |
||
| 74 | # @private |
||
| 75 | onHoverMove: (x, y) => |
||
| 76 | index = @hitTest(x) |
||
| 77 | @displayHoverForRow(index) |
||
| 78 | |||
| 79 | # hover out event handler |
||
| 80 | # |
||
| 81 | # @private |
||
| 82 | onHoverOut: => |
||
| 83 | if @options.hideHover isnt false |
||
| 84 | @displayHoverForRow(null) |
||
| 85 | |||
| 86 | # display a hover popup over the given row |
||
| 87 | # |
||
| 88 | # @private |
||
| 89 | displayHoverForRow: (index) -> |
||
| 90 | if index? |
||
| 91 | @hover.update(@hoverContentForRow(index)...) |
||
| 92 | @hilight(index) |
||
| 93 | else |
||
| 94 | @hover.hide() |
||
| 95 | @hilight() |
||
| 96 | |||
| 97 | # hover content for a point |
||
| 98 | # |
||
| 99 | # @private |
||
| 100 | hoverContentForRow: (index) -> |
||
| 101 | row = @data[index] |
||
| 102 | content = "<div class='morris-hover-row-label'>#{row.label}</div>" |
||
| 103 | for y, j in row.y |
||
| 104 | content += """ |
||
| 105 | <div class='morris-hover-point' style='color: #{@colorFor(row, j, 'label')}'> |
||
| 106 | #{@options.labels[j]}: |
||
| 107 | #{@yLabelFormat(y)} |
||
| 108 | </div> |
||
| 109 | """ |
||
| 110 | if typeof @options.hoverCallback is 'function' |
||
| 111 | content = @options.hoverCallback(index, @options, content, row.src) |
||
| 112 | [content, row._x, row._ymax] |
||
| 113 | |||
| 114 | |||
| 115 | # generate paths for series lines |
||
| 116 | # |
||
| 117 | # @private |
||
| 118 | generatePaths: -> |
||
| 119 | @paths = for i in [0...@options.ykeys.length] |
||
| 120 | smooth = if typeof @options.smooth is "boolean" then @options.smooth else @options.ykeys[i] in @options.smooth |
||
| 121 | coords = ({x: r._x, y: r._y[i]} for r in @data when r._y[i] isnt undefined) |
||
| 122 | |||
| 123 | if coords.length > 1 |
||
| 124 | Morris.Line.createPath coords, smooth, @bottom |
||
| 125 | else |
||
| 126 | null |
||
| 127 | |||
| 128 | # Draws the line chart. |
||
| 129 | # |
||
| 130 | draw: -> |
||
| 131 | @drawXAxis() if @options.axes in [true, 'both', 'x'] |
||
| 132 | @drawSeries() |
||
| 133 | if @options.hideHover is false |
||
| 134 | @displayHoverForRow(@data.length - 1) |
||
| 135 | |||
| 136 | # draw the x-axis labels |
||
| 137 | # |
||
| 138 | # @private |
||
| 139 | drawXAxis: -> |
||
| 140 | # draw x axis labels |
||
| 141 | ypos = @bottom + @options.padding / 2 |
||
| 142 | prevLabelMargin = null |
||
| 143 | prevAngleMargin = null |
||
| 144 | drawLabel = (labelText, xpos) => |
||
| 145 | label = @drawXAxisLabel(@transX(xpos), ypos, labelText) |
||
| 146 | textBox = label.getBBox() |
||
| 147 | label.transform("r#{-@options.xLabelAngle}") |
||
| 148 | labelBox = label.getBBox() |
||
| 149 | label.transform("t0,#{labelBox.height / 2}...") |
||
| 150 | if @options.xLabelAngle != 0 |
||
| 151 | offset = -0.5 * textBox.width * |
||
| 152 | Math.cos(@options.xLabelAngle * Math.PI / 180.0) |
||
| 153 | label.transform("t#{offset},0...") |
||
| 154 | # try to avoid overlaps |
||
| 155 | labelBox = label.getBBox() |
||
| 156 | if (not prevLabelMargin? or |
||
| 157 | prevLabelMargin >= labelBox.x + labelBox.width or |
||
| 158 | prevAngleMargin? and prevAngleMargin >= labelBox.x) and |
||
| 159 | labelBox.x >= 0 and (labelBox.x + labelBox.width) < @el.width() |
||
| 160 | if @options.xLabelAngle != 0 |
||
| 161 | margin = 1.25 * @options.gridTextSize / |
||
| 162 | Math.sin(@options.xLabelAngle * Math.PI / 180.0) |
||
| 163 | prevAngleMargin = labelBox.x - margin |
||
| 164 | prevLabelMargin = labelBox.x - @options.xLabelMargin |
||
| 165 | else |
||
| 166 | label.remove() |
||
| 167 | if @options.parseTime |
||
| 168 | if @data.length == 1 and @options.xLabels == 'auto' |
||
| 169 | # where there's only one value in the series, we can't make a |
||
| 170 | # sensible guess for an x labelling scheme, so just use the original |
||
| 171 | # column label |
||
| 172 | labels = [[@data[0].label, @data[0].x]] |
||
| 173 | else |
||
| 174 | labels = Morris.labelSeries(@xmin, @xmax, @width, @options.xLabels, @options.xLabelFormat) |
||
| 175 | else |
||
| 176 | labels = ([row.label, row.x] for row in @data) |
||
| 177 | labels.reverse() |
||
| 178 | for l in labels |
||
| 179 | drawLabel(l[0], l[1]) |
||
| 180 | |||
| 181 | # draw the data series |
||
| 182 | # |
||
| 183 | # @private |
||
| 184 | drawSeries: -> |
||
| 185 | @seriesPoints = [] |
||
| 186 | for i in [@options.ykeys.length-1..0] |
||
| 187 | @_drawLineFor i |
||
| 188 | for i in [@options.ykeys.length-1..0] |
||
| 189 | @_drawPointFor i |
||
| 190 | |||
| 191 | _drawPointFor: (index) -> |
||
| 192 | @seriesPoints[index] = [] |
||
| 193 | for row in @data |
||
| 194 | circle = null |
||
| 195 | if row._y[index]? |
||
| 196 | circle = @drawLinePoint(row._x, row._y[index], @colorFor(row, index, 'point'), index) |
||
| 197 | @seriesPoints[index].push(circle) |
||
| 198 | |||
| 199 | _drawLineFor: (index) -> |
||
| 200 | path = @paths[index] |
||
| 201 | if path isnt null |
||
| 202 | @drawLinePath path, @colorFor(null, index, 'line'), index |
||
| 203 | |||
| 204 | # create a path for a data series |
||
| 205 | # |
||
| 206 | # @private |
||
| 207 | @createPath: (coords, smooth, bottom) -> |
||
| 208 | path = "" |
||
| 209 | grads = Morris.Line.gradients(coords) if smooth |
||
| 210 | |||
| 211 | prevCoord = {y: null} |
||
| 212 | for coord, i in coords |
||
| 213 | if coord.y? |
||
| 214 | if prevCoord.y? |
||
| 215 | if smooth |
||
| 216 | g = grads[i] |
||
| 217 | lg = grads[i - 1] |
||
| 218 | ix = (coord.x - prevCoord.x) / 4 |
||
| 219 | x1 = prevCoord.x + ix |
||
| 220 | y1 = Math.min(bottom, prevCoord.y + ix * lg) |
||
| 221 | x2 = coord.x - ix |
||
| 222 | y2 = Math.min(bottom, coord.y - ix * g) |
||
| 223 | path += "C#{x1},#{y1},#{x2},#{y2},#{coord.x},#{coord.y}" |
||
| 224 | else |
||
| 225 | path += "L#{coord.x},#{coord.y}" |
||
| 226 | else |
||
| 227 | if not smooth or grads[i]? |
||
| 228 | path += "M#{coord.x},#{coord.y}" |
||
| 229 | prevCoord = coord |
||
| 230 | return path |
||
| 231 | |||
| 232 | # calculate a gradient at each point for a series of points |
||
| 233 | # |
||
| 234 | # @private |
||
| 235 | @gradients: (coords) -> |
||
| 236 | grad = (a, b) -> (a.y - b.y) / (a.x - b.x) |
||
| 237 | for coord, i in coords |
||
| 238 | if coord.y? |
||
| 239 | nextCoord = coords[i + 1] or {y: null} |
||
| 240 | prevCoord = coords[i - 1] or {y: null} |
||
| 241 | if prevCoord.y? and nextCoord.y? |
||
| 242 | grad(prevCoord, nextCoord) |
||
| 243 | else if prevCoord.y? |
||
| 244 | grad(prevCoord, coord) |
||
| 245 | else if nextCoord.y? |
||
| 246 | grad(coord, nextCoord) |
||
| 247 | else |
||
| 248 | null |
||
| 249 | else |
||
| 250 | null |
||
| 251 | |||
| 252 | # @private |
||
| 253 | hilight: (index) => |
||
| 254 | if @prevHilight isnt null and @prevHilight isnt index |
||
| 255 | for i in [0..@seriesPoints.length-1] |
||
| 256 | if @seriesPoints[i][@prevHilight] |
||
| 257 | @seriesPoints[i][@prevHilight].animate @pointShrinkSeries(i) |
||
| 258 | if index isnt null and @prevHilight isnt index |
||
| 259 | for i in [0..@seriesPoints.length-1] |
||
| 260 | if @seriesPoints[i][index] |
||
| 261 | @seriesPoints[i][index].animate @pointGrowSeries(i) |
||
| 262 | @prevHilight = index |
||
| 263 | |||
| 264 | colorFor: (row, sidx, type) -> |
||
| 265 | if typeof @options.lineColors is 'function' |
||
| 266 | @options.lineColors.call(@, row, sidx, type) |
||
| 267 | else if type is 'point' |
||
| 268 | @options.pointFillColors[sidx % @options.pointFillColors.length] || @options.lineColors[sidx % @options.lineColors.length] |
||
| 269 | else |
||
| 270 | @options.lineColors[sidx % @options.lineColors.length] |
||
| 271 | |||
| 272 | drawXAxisLabel: (xPos, yPos, text) -> |
||
| 273 | @raphael.text(xPos, yPos, text) |
||
| 274 | .attr('font-size', @options.gridTextSize) |
||
| 275 | .attr('font-family', @options.gridTextFamily) |
||
| 276 | .attr('font-weight', @options.gridTextWeight) |
||
| 277 | .attr('fill', @options.gridTextColor) |
||
| 278 | |||
| 279 | drawLinePath: (path, lineColor, lineIndex) -> |
||
| 280 | @raphael.path(path) |
||
| 281 | .attr('stroke', lineColor) |
||
| 282 | .attr('stroke-width', @lineWidthForSeries(lineIndex)) |
||
| 283 | |||
| 284 | drawLinePoint: (xPos, yPos, pointColor, lineIndex) -> |
||
| 285 | @raphael.circle(xPos, yPos, @pointSizeForSeries(lineIndex)) |
||
| 286 | .attr('fill', pointColor) |
||
| 287 | .attr('stroke-width', @pointStrokeWidthForSeries(lineIndex)) |
||
| 288 | .attr('stroke', @pointStrokeColorForSeries(lineIndex)) |
||
| 289 | |||
| 290 | # @private |
||
| 291 | pointStrokeWidthForSeries: (index) -> |
||
| 292 | @options.pointStrokeWidths[index % @options.pointStrokeWidths.length] |
||
| 293 | |||
| 294 | # @private |
||
| 295 | pointStrokeColorForSeries: (index) -> |
||
| 296 | @options.pointStrokeColors[index % @options.pointStrokeColors.length] |
||
| 297 | |||
| 298 | # @private |
||
| 299 | lineWidthForSeries: (index) -> |
||
| 300 | if (@options.lineWidth instanceof Array) |
||
| 301 | @options.lineWidth[index % @options.lineWidth.length] |
||
| 302 | else |
||
| 303 | @options.lineWidth |
||
| 304 | |||
| 305 | # @private |
||
| 306 | pointSizeForSeries: (index) -> |
||
| 307 | if (@options.pointSize instanceof Array) |
||
| 308 | @options.pointSize[index % @options.pointSize.length] |
||
| 309 | else |
||
| 310 | @options.pointSize |
||
| 311 | |||
| 312 | # @private |
||
| 313 | pointGrowSeries: (index) -> |
||
| 314 | Raphael.animation r: @pointSizeForSeries(index) + 3, 25, 'linear' |
||
| 315 | |||
| 316 | # @private |
||
| 317 | pointShrinkSeries: (index) -> |
||
| 318 | Raphael.animation r: @pointSizeForSeries(index), 25, 'linear' |
||
| 319 | |||
| 320 | # generate a series of label, timestamp pairs for x-axis labels |
||
| 321 | # |
||
| 322 | # @private |
||
| 323 | Morris.labelSeries = (dmin, dmax, pxwidth, specName, xLabelFormat) -> |
||
| 324 | ddensity = 200 * (dmax - dmin) / pxwidth # seconds per `margin` pixels |
||
| 325 | d0 = new Date(dmin) |
||
| 326 | spec = Morris.LABEL_SPECS[specName] |
||
| 327 | # if the spec doesn't exist, search for the closest one in the list |
||
| 328 | if spec is undefined |
||
| 329 | for name in Morris.AUTO_LABEL_ORDER |
||
| 330 | s = Morris.LABEL_SPECS[name] |
||
| 331 | if ddensity >= s.span |
||
| 332 | spec = s |
||
| 333 | break |
||
| 334 | # if we run out of options, use second-intervals |
||
| 335 | if spec is undefined |
||
| 336 | spec = Morris.LABEL_SPECS["second"] |
||
| 337 | # check if there's a user-defined formatting function |
||
| 338 | if xLabelFormat |
||
| 339 | spec = $.extend({}, spec, {fmt: xLabelFormat}) |
||
| 340 | # calculate labels |
||
| 341 | d = spec.start(d0) |
||
| 342 | ret = [] |
||
| 343 | while (t = d.getTime()) <= dmax |
||
| 344 | if t >= dmin |
||
| 345 | ret.push [spec.fmt(d), t] |
||
| 346 | spec.incr(d) |
||
| 347 | return ret |
||
| 348 | |||
| 349 | # @private |
||
| 350 | minutesSpecHelper = (interval) -> |
||
| 351 | span: interval * 60 * 1000 |
||
| 352 | start: (d) -> new Date(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours()) |
||
| 353 | fmt: (d) -> "#{Morris.pad2(d.getHours())}:#{Morris.pad2(d.getMinutes())}" |
||
| 354 | incr: (d) -> d.setUTCMinutes(d.getUTCMinutes() + interval) |
||
| 355 | |||
| 356 | # @private |
||
| 357 | secondsSpecHelper = (interval) -> |
||
| 358 | span: interval * 1000 |
||
| 359 | start: (d) -> new Date(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours(), d.getMinutes()) |
||
| 360 | fmt: (d) -> "#{Morris.pad2(d.getHours())}:#{Morris.pad2(d.getMinutes())}:#{Morris.pad2(d.getSeconds())}" |
||
| 361 | incr: (d) -> d.setUTCSeconds(d.getUTCSeconds() + interval) |
||
| 362 | |||
| 363 | Morris.LABEL_SPECS = |
||
| 364 | "decade": |
||
| 365 | span: 172800000000 # 10 * 365 * 24 * 60 * 60 * 1000 |
||
| 366 | start: (d) -> new Date(d.getFullYear() - d.getFullYear() % 10, 0, 1) |
||
| 367 | fmt: (d) -> "#{d.getFullYear()}" |
||
| 368 | incr: (d) -> d.setFullYear(d.getFullYear() + 10) |
||
| 369 | "year": |
||
| 370 | span: 17280000000 # 365 * 24 * 60 * 60 * 1000 |
||
| 371 | start: (d) -> new Date(d.getFullYear(), 0, 1) |
||
| 372 | fmt: (d) -> "#{d.getFullYear()}" |
||
| 373 | incr: (d) -> d.setFullYear(d.getFullYear() + 1) |
||
| 374 | "month": |
||
| 375 | span: 2419200000 # 28 * 24 * 60 * 60 * 1000 |
||
| 376 | start: (d) -> new Date(d.getFullYear(), d.getMonth(), 1) |
||
| 377 | fmt: (d) -> "#{d.getFullYear()}-#{Morris.pad2(d.getMonth() + 1)}" |
||
| 378 | incr: (d) -> d.setMonth(d.getMonth() + 1) |
||
| 379 | "week": |
||
| 380 | span: 604800000 # 7 * 24 * 60 * 60 * 1000 |
||
| 381 | start: (d) -> new Date(d.getFullYear(), d.getMonth(), d.getDate()) |
||
| 382 | fmt: (d) -> "#{d.getFullYear()}-#{Morris.pad2(d.getMonth() + 1)}-#{Morris.pad2(d.getDate())}" |
||
| 383 | incr: (d) -> d.setDate(d.getDate() + 7) |
||
| 384 | "day": |
||
| 385 | span: 86400000 # 24 * 60 * 60 * 1000 |
||
| 386 | start: (d) -> new Date(d.getFullYear(), d.getMonth(), d.getDate()) |
||
| 387 | fmt: (d) -> "#{d.getFullYear()}-#{Morris.pad2(d.getMonth() + 1)}-#{Morris.pad2(d.getDate())}" |
||
| 388 | incr: (d) -> d.setDate(d.getDate() + 1) |
||
| 389 | "hour": minutesSpecHelper(60) |
||
| 390 | "30min": minutesSpecHelper(30) |
||
| 391 | "15min": minutesSpecHelper(15) |
||
| 392 | "10min": minutesSpecHelper(10) |
||
| 393 | "5min": minutesSpecHelper(5) |
||
| 394 | "minute": minutesSpecHelper(1) |
||
| 395 | "30sec": secondsSpecHelper(30) |
||
| 396 | "15sec": secondsSpecHelper(15) |
||
| 397 | "10sec": secondsSpecHelper(10) |
||
| 398 | "5sec": secondsSpecHelper(5) |
||
| 399 | "second": secondsSpecHelper(1) |
||
| 400 | |||
| 401 | Morris.AUTO_LABEL_ORDER = [ |
||
| 402 | "decade", "year", "month", "week", "day", "hour", |
||
| 403 | "30min", "15min", "10min", "5min", "minute", |
||
| 404 | "30sec", "15sec", "10sec", "5sec", "second" |
||
| 405 | ] |