morris.grid.coffee 14 KB

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