morris.line.coffee 12 KB

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