class Morris.Line extends Morris.Grid # Initialise the graph. # constructor: (options) -> return new Morris.Line(options) unless (@ instanceof Morris.Line) super(options) init: -> # Some instance variables for later if @options.hideHover isnt 'always' @hover = new Morris.Hover(parent: @el) @on('hovermove', @onHoverMove) @on('hoverout', @onHoverOut) @on('gridclick', @onGridClick) # Default configuration # defaults: lineWidth: 3 pointSize: 4 lineColors: [ '#0b62a4' '#7A92A3' '#4da74d' '#afd8f8' '#edc240' '#cb4b4b' '#9440ed' ] pointStrokeWidths: [1] pointStrokeColors: ['#ffffff'] pointFillColors: [] smooth: true shown: true xLabels: 'auto' xLabelFormat: null xLabelMargin: 24 verticalGrid: false verticalGridHeight: 'full' verticalGridStartOffset: 0 hideHover: false trendLine: false trendLineWidth: 2 trendLineWeight: false trendLineColors: [ '#689bc3' '#a2b3bf' '#64b764' ] # Do any size-related calculations # # @private calc: -> @calcPoints() @generatePaths() # calculate series data point coordinates # # @private calcPoints: -> for row in @data row._x = @transX(row.x) row._y = for y in row.y if y? then @transY(y) else y row._ymax = Math.min [@bottom].concat(y for y, i in row._y when y? and @hasToShow(i))... # hit test - returns the index of the row at the given x-coordinate # hitTest: (x) -> return null if @data.length == 0 # TODO better search algo for r, index in @data.slice(1) break if x < (r._x + @data[index]._x) / 2 index # click on grid event handler # # @private onGridClick: (x, y) => index = @hitTest(x) @fire 'click', index, @data[index].src, x, y # hover movement event handler # # @private onHoverMove: (x, y) => index = @hitTest(x) @displayHoverForRow(index) # hover out event handler # # @private onHoverOut: => if @options.hideHover isnt false @displayHoverForRow(null) # display a hover popup over the given row # # @private displayHoverForRow: (index) -> if index? @hover.update(@hoverContentForRow(index)...) @hilight(index) else @hover.hide() @hilight() # hover content for a point # # @private hoverContentForRow: (index) -> row = @data[index] content = $("
").text(row.label) content = content.prop('outerHTML') for y, j in row.y if @options.labels[j] is false continue content += """
#{@options.labels[j]}: #{@yLabelFormat(y, j)}
""" if typeof @options.hoverCallback is 'function' content = @options.hoverCallback(index, @options, content, row.src) [content, row._x, row._ymax] # generate paths for series lines # # @private generatePaths: -> @paths = for i in [0...@options.ykeys.length] smooth = if typeof @options.smooth is "boolean" then @options.smooth else @options.ykeys[i] in @options.smooth coords = ({x: r._x, y: r._y[i]} for r in @data when r._y[i] isnt undefined) if coords.length > 1 Morris.Line.createPath coords, smooth, @bottom else null # Draws the line chart. # draw: -> @drawXAxis() if @options.axes in [true, 'both', 'x'] @drawSeries() if @options.hideHover is false @displayHoverForRow(@data.length - 1) # draw the x-axis labels # # @private drawXAxis: -> # draw x axis labels ypos = @bottom + @options.padding / 2 prevLabelMargin = null prevAngleMargin = null drawLabel = (labelText, xpos) => label = @drawXAxisLabel(@transX(xpos), ypos, labelText) textBox = label.getBBox() label.transform("r#{-@options.xLabelAngle}") labelBox = label.getBBox() label.transform("t0,#{labelBox.height / 2}...") if @options.xLabelAngle != 0 offset = -0.5 * textBox.width * Math.cos(@options.xLabelAngle * Math.PI / 180.0) label.transform("t#{offset},0...") # try to avoid overlaps labelBox = label.getBBox() if (not prevLabelMargin? or prevLabelMargin >= labelBox.x + labelBox.width or prevAngleMargin? and prevAngleMargin >= labelBox.x) and labelBox.x >= 0 and (labelBox.x + labelBox.width) < @el.width() if @options.xLabelAngle != 0 margin = 1.25 * @options.gridTextSize / Math.sin(@options.xLabelAngle * Math.PI / 180.0) prevAngleMargin = labelBox.x - margin prevLabelMargin = labelBox.x - @options.xLabelMargin if @options.verticalGrid is true @drawVerticalGridLine(xpos) else label.remove() if @options.parseTime if @data.length == 1 and @options.xLabels == 'auto' # where there's only one value in the series, we can't make a # sensible guess for an x labelling scheme, so just use the original # column label labels = [[@data[0].label, @data[0].x]] else labels = Morris.labelSeries(@xmin, @xmax, @width, @options.xLabels, @options.xLabelFormat) else if @options.customLabels labels = ([row.label, row.x] for row in @options.customLabels) else labels = ([row.label, row.x] for row in @data) labels.reverse() for l in labels drawLabel(l[0], l[1]) if typeof @options.verticalGrid is 'string' lines = Morris.labelSeries(@xmin, @xmax, @width, @options.verticalGrid) for l in lines @drawVerticalGridLine(l[1]) # Draw a vertical grid line # # @private drawVerticalGridLine: (xpos) -> xpos = Math.floor(@transX(xpos)) + 0.5 yStart = @yStart + @options.verticalGridStartOffset if @options.verticalGridHeight is 'full' yEnd = @yEnd else yEnd = @yStart - @options.verticalGridHeight @drawGridLine("M#{xpos},#{yStart}V#{yEnd}") # draw the data series # # @private drawSeries: -> @seriesPoints = [] for i in [@options.ykeys.length-1..0] if @hasToShow(i) if @options.trendLine isnt false and @options.trendLine is true or @options.trendLine[i] is true @_drawTrendLine i @_drawLineFor i for i in [@options.ykeys.length-1..0] if @hasToShow(i) @_drawPointFor i _drawPointFor: (index) -> @seriesPoints[index] = [] for row in @data circle = null if row._y[index]? circle = @drawLinePoint(row._x, row._y[index], @colorFor(row, index, 'point'), index) @seriesPoints[index].push(circle) _drawLineFor: (index) -> path = @paths[index] if path isnt null @drawLinePath path, @colorFor(null, index, 'line'), index _drawTrendLine: (index) -> # Least squares fitting for y = x * a + b sum_x = 0 sum_y = 0 sum_xx = 0 sum_xy = 0 datapoints = 0 for val, i in @data x = val.x y = val.y[index] if y is undefined continue if @options.trendLineWeight is false weight = 1 else weight = @options.data[i][@options.trendLineWeight] datapoints += weight sum_x += x * weight sum_y += y * weight sum_xx += x * x * weight sum_xy += x * y * weight a = (datapoints*sum_xy - sum_x*sum_y) / (datapoints*sum_xx - sum_x*sum_x) b = (sum_y / datapoints) - ((a * sum_x) / datapoints) data = [{}, {}] data[0].x = @transX(@data[0].x) data[0].y = @transY(@data[0].x * a + b) data[1].x = @transX(@data[@data.length - 1].x) data[1].y = @transY(@data[@data.length - 1].x * a + b) path = Morris.Line.createPath data, false, @bottom path = @raphael.path(path) .attr('stroke', @colorFor(null, index, 'trendLine')) .attr('stroke-width', @options.trendLineWidth) # create a path for a data series # # @private @createPath: (coords, smooth, bottom) -> path = "" grads = Morris.Line.gradients(coords) if smooth prevCoord = {y: null} for coord, i in coords if coord.y? if prevCoord.y? if smooth g = grads[i] lg = grads[i - 1] ix = (coord.x - prevCoord.x) / 4 x1 = prevCoord.x + ix y1 = Math.min(bottom, prevCoord.y + ix * lg) x2 = coord.x - ix y2 = Math.min(bottom, coord.y - ix * g) path += "C#{x1},#{y1},#{x2},#{y2},#{coord.x},#{coord.y}" else path += "L#{coord.x},#{coord.y}" else if not smooth or grads[i]? path += "M#{coord.x},#{coord.y}" prevCoord = coord return path # calculate a gradient at each point for a series of points # # @private @gradients: (coords) -> grad = (a, b) -> (a.y - b.y) / (a.x - b.x) for coord, i in coords if coord.y? nextCoord = coords[i + 1] or {y: null} prevCoord = coords[i - 1] or {y: null} if prevCoord.y? and nextCoord.y? grad(prevCoord, nextCoord) else if prevCoord.y? grad(prevCoord, coord) else if nextCoord.y? grad(coord, nextCoord) else null else null # @private hilight: (index) => if @prevHilight isnt null and @prevHilight isnt index for i in [0..@seriesPoints.length-1] if @hasToShow(i) and @seriesPoints[i][@prevHilight] @seriesPoints[i][@prevHilight].animate @pointShrinkSeries(i) if index isnt null and @prevHilight isnt index for i in [0..@seriesPoints.length-1] if @hasToShow(i) and @seriesPoints[i][index] @seriesPoints[i][index].animate @pointGrowSeries(i) @prevHilight = index colorFor: (row, sidx, type) -> if typeof @options.lineColors is 'function' @options.lineColors.call(@, row, sidx, type) else if type is 'point' @options.pointFillColors[sidx % @options.pointFillColors.length] || @options.lineColors[sidx % @options.lineColors.length] else if type is 'trendLine' @options.trendLineColors[sidx % @options.trendLineColors.length] else @options.lineColors[sidx % @options.lineColors.length] drawLinePath: (path, lineColor, lineIndex) -> @raphael.path(path) .attr('stroke', lineColor) .attr('stroke-width', @lineWidthForSeries(lineIndex)) drawLinePoint: (xPos, yPos, pointColor, lineIndex) -> @raphael.circle(xPos, yPos, @pointSizeForSeries(lineIndex)) .attr('fill', pointColor) .attr('stroke-width', @pointStrokeWidthForSeries(lineIndex)) .attr('stroke', @pointStrokeColorForSeries(lineIndex)) # @private pointStrokeWidthForSeries: (index) -> @options.pointStrokeWidths[index % @options.pointStrokeWidths.length] # @private pointStrokeColorForSeries: (index) -> @options.pointStrokeColors[index % @options.pointStrokeColors.length] # @private lineWidthForSeries: (index) -> if (@options.lineWidth instanceof Array) @options.lineWidth[index % @options.lineWidth.length] else @options.lineWidth # @private pointSizeForSeries: (index) -> if (@options.pointSize instanceof Array) @options.pointSize[index % @options.pointSize.length] else @options.pointSize # @private pointGrowSeries: (index) -> if @pointSizeForSeries(index) is 0 return Raphael.animation r: @pointSizeForSeries(index) + 3, 25, 'linear' # @private pointShrinkSeries: (index) -> Raphael.animation r: @pointSizeForSeries(index), 25, 'linear' # generate a series of label, timestamp pairs for x-axis labels # # @private Morris.labelSeries = (dmin, dmax, pxwidth, specName, xLabelFormat) -> ddensity = 200 * (dmax - dmin) / pxwidth # seconds per `margin` pixels d0 = new Date(dmin) spec = Morris.LABEL_SPECS[specName] # if the spec doesn't exist, search for the closest one in the list if spec is undefined for name in Morris.AUTO_LABEL_ORDER s = Morris.LABEL_SPECS[name] if ddensity >= s.span spec = s break # if we run out of options, use second-intervals if spec is undefined spec = Morris.LABEL_SPECS["second"] # check if there's a user-defined formatting function if xLabelFormat spec = $.extend({}, spec, {fmt: xLabelFormat}) # calculate labels d = spec.start(d0) ret = [] while (t = d.getTime()) <= dmax if t >= dmin ret.push [spec.fmt(d), t] spec.incr(d) return ret # @private minutesSpecHelper = (interval) -> span: interval * 60 * 1000 start: (d) -> new Date(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours()) fmt: (d) -> "#{Morris.pad2(d.getHours())}:#{Morris.pad2(d.getMinutes())}" incr: (d) -> d.setUTCMinutes(d.getUTCMinutes() + interval) # @private secondsSpecHelper = (interval) -> span: interval * 1000 start: (d) -> new Date(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours(), d.getMinutes()) fmt: (d) -> "#{Morris.pad2(d.getHours())}:#{Morris.pad2(d.getMinutes())}:#{Morris.pad2(d.getSeconds())}" incr: (d) -> d.setUTCSeconds(d.getUTCSeconds() + interval) Morris.LABEL_SPECS = "decade": span: 172800000000 # 10 * 365 * 24 * 60 * 60 * 1000 start: (d) -> new Date(d.getFullYear() - d.getFullYear() % 10, 0, 1) fmt: (d) -> "#{d.getFullYear()}" incr: (d) -> d.setFullYear(d.getFullYear() + 10) "year": span: 17280000000 # 365 * 24 * 60 * 60 * 1000 start: (d) -> new Date(d.getFullYear(), 0, 1) fmt: (d) -> "#{d.getFullYear()}" incr: (d) -> d.setFullYear(d.getFullYear() + 1) "month": span: 2419200000 # 28 * 24 * 60 * 60 * 1000 start: (d) -> new Date(d.getFullYear(), d.getMonth(), 1) fmt: (d) -> "#{d.getFullYear()}-#{Morris.pad2(d.getMonth() + 1)}" incr: (d) -> d.setMonth(d.getMonth() + 1) "week": span: 604800000 # 7 * 24 * 60 * 60 * 1000 start: (d) -> new Date(d.getFullYear(), d.getMonth(), d.getDate()) fmt: (d) -> "#{d.getFullYear()}-#{Morris.pad2(d.getMonth() + 1)}-#{Morris.pad2(d.getDate())}" incr: (d) -> d.setDate(d.getDate() + 7) "day": span: 86400000 # 24 * 60 * 60 * 1000 start: (d) -> new Date(d.getFullYear(), d.getMonth(), d.getDate()) fmt: (d) -> "#{d.getFullYear()}-#{Morris.pad2(d.getMonth() + 1)}-#{Morris.pad2(d.getDate())}" incr: (d) -> d.setDate(d.getDate() + 1) "hour": minutesSpecHelper(60) "30min": minutesSpecHelper(30) "15min": minutesSpecHelper(15) "10min": minutesSpecHelper(10) "5min": minutesSpecHelper(5) "minute": minutesSpecHelper(1) "30sec": secondsSpecHelper(30) "15sec": secondsSpecHelper(15) "10sec": secondsSpecHelper(10) "5sec": secondsSpecHelper(5) "second": secondsSpecHelper(1) Morris.AUTO_LABEL_ORDER = [ "decade", "year", "month", "week", "day", "hour", "30min", "15min", "10min", "5min", "minute", "30sec", "15sec", "10sec", "5sec", "second" ]