diff --git a/example.html b/example.html index e4d4ad1..01ae360 100644 --- a/example.html +++ b/example.html @@ -45,7 +45,7 @@ {"period": "1995 Q4", "licensed": 1702, "sorned": 0}, {"period": "1994 Q4", "licensed": 1732, "sorned": 0} ]; - $('#graph').hml({ + new Morris.Line("graph", { data: tax_data, xkey: 'period', ykeys: ['licensed', 'sorned'], @@ -53,4 +53,4 @@ }); }) - \ No newline at end of file + diff --git a/morris.coffee b/morris.coffee new file mode 100644 index 0000000..42c323a --- /dev/null +++ b/morris.coffee @@ -0,0 +1,192 @@ +# The original line graph. +# +window.Morris = {} +class window.Morris.Line + + # Initialise the graph. + # + # @param {string} id Target element's DOM ID + # @param {Object} options + constructor: (id, options) -> + @el = $ document.getElementById(id) + @options = $.extend @defaults, options + # bail if there's no data + if @options.data is undefined or @options.data.length is 0 + return + @el.addClass 'graph-initialised' + @precalc() + @redraw() + + # Default configuration + # + defaults: + lineWidth: 3 + pointSize: 4 + lineColors: [ + '#0b62a4' + '#7A92A3' + '#4da74d' + '#afd8f8' + '#edc240' + '#cb4b4b' + '#9440ed' + ] + marginTop: 25 + marginRight: 25 + marginBottom: 30 + marginLeft: 25 + numLines: 5 + gridLineColor: '#aaa' + gridTextColor: '#888' + gridTextSize: 12 + gridStrokeWidth: 0.5 + + # Do any necessary pre-processing for a new dataset + # + precalc: -> + # extract labels + @xlabels = $.map @options.data, (d) => d[@options.xkey] + @ylabels = @options.labels + + # extract series data + @series = [] + for ykey in @options.ykeys + @series.push $.map @options.data, (d) -> d[ykey] + + # translate x labels into nominal dates + # note: currently using decimal years to specify dates + @xvals = $.map @xlabels, (x) => @parseYear x + @xmin = Math.min.apply null, @xvals + @xmax = Math.max.apply null, @xvals + if @xmin is @xmax + @xmin -= 1 + @xmax += 1 + + # use $.map to flatten arrays and find the max y value + all_y_vals = $.map @series, (x) -> Math.max.apply null, x + @ymax = Math.max(20, Math.max.apply(null, all_y_vals)) + + # Clear and redraw the graph + # + redraw: -> + # remove child elements (get rid of old drawings) + @el.empty() + + # the raphael drawing instance + @r = new Raphael(@el[0]) + + # calculate grid dimensions + left = @measureText(@ymax, @options.gridTextSize).width + @options.marginLeft + width = @el.width() - left - @options.marginRight + height = @el.height() - @options.marginTop - @options.marginBottom + dx = width / (@xmax - @xmin) + dy = height / @ymax + + # quick translation helpers + transX = (x) => + if @xvals.length is 1 + left + width / 2 + else + left + (x - @xmin) * dx + transY = (y) => + return @options.marginTop + height - y * dy + + # draw y axis labels, horizontal lines + lineInterval = height / (@options.numLines - 1) + for i in [0..@options.numLines-1] + y = @options.marginTop + i * lineInterval + v = Math.round((@options.numLines - 1 - i) * @ymax / (@options.numLines - 1)) + @r.text(left - @options.marginLeft/2, y, v) + .attr('font-size', @options.gridTextSize) + .attr('fill', @options.gridTextColor) + .attr('text-anchor', 'end') + @r.path("M" + left + "," + y + 'H' + (left + width)) + .attr('stroke', @options.gridLineColor) + .attr('stroke-width', @options.gridStrokeWidth) + + # draw x axis labels + prevLabelMargin = null + xLabelMargin = 50 # make this an option? + for i in [Math.ceil(@xmin)..Math.floor(@xmax)] + label = @r.text(transX(i), @options.marginTop + height + @options.marginBottom / 2, i) + .attr('font-size', @options.gridTextSize) + .attr('fill', @options.gridTextColor) + labelBox = label.getBBox() + # ensure a minimum of `xLabelMargin` pixels between labels + if prevLabelMargin is null or prevLabelMargin <= labelBox.x + prevLabelMargin = labelBox.x + labelBox.width + xLabelMargin + else + label.remove() + + # draw the actual series + columns = (transX(x) for x in @xvals) + seriesCoords = [] + for s in @series + seriesCoords.push($.map(s, (y, i) -> x: columns[i], y: transY(y))) + for i in [seriesCoords.length-1..0] + coords = seriesCoords[i] + if coords.length > 1 + path = @createPath coords, @options.marginTop, left, @options.marginTop + height, left + width + @r.path(path) + .attr('stroke', @options.lineColors[i]) + .attr('stroke-width', @options.lineWidth) + seriesPoints = ([] for i in [0..seriesCoords.length-1]) + for i in [seriesCoords.length-1..0] + for c in seriesCoords[i] + circle = @r.circle(c.x, c.y, @options.pointSize) + .attr('fill', @options.lineColors[i]) + .attr('stroke-width', 1) + .attr('stroke', '#ffffff') + seriesPoints[i].push(circle) + + # hover labels + hoverMargins = $.map columns.slice(1), (x, i) -> (x + columns[i]) / 2 + + # create a path for a data series + # + createPath: (coords, top, left, bottom, right) -> + path = "" + grads = @gradients coords + for i in [0..coords.length-1] + c = coords[i] + if i is 0 + path += "M#{c.x},#{c.y}" + else + g = grads[i] + lc = coords[i - 1] + lg = grads[i - 1] + ix = (c.x - lc.x) / 4 + x1 = lc.x + ix + y1 = Math.min(bottom, lc.y + ix * lg) + x2 = c.x - ix + y2 = Math.min(bottom, c.y - ix * g) + path += "C#{x1},#{y1},#{x2},#{y2},#{c.x},#{c.y}" + return path + + # calculate a gradient at each point for a series of points + # + gradients: (coords) -> + $.map coords, (c, i) -> + if i is 0 + (coords[1].y - c.y) / (coords[1].x - c.x) + else if i is (coords.length - 1) + (c.y - coords[i - 1].y) / (c.x - coords[i - 1].x) + else + (coords[i + 1].y - coords[i - 1].y) / (coords[i + 1].x - coords[i - 1].x) + + measureText: (text, fontSize = 12) -> + tt = @r.text(100, 100, text).attr('font-size', fontSize) + ret = tt.getBBox() + tt.remove() + return ret + + parseYear: (year) -> + m = year.toString().match /(\d+) Q(\d)/ + n = year.toString().match /(\d+)\-(\d+)/ + if m + parseInt(m[1], 10) + (parseInt(m[2], 10) * 3 - 1) / 12 + else if n + parseInt(n[1], 10) + (parseInt(n[2], 10) - 1) / 12 + else + parseInt(year, 10) + diff --git a/morris.js b/morris.js index 3e5f81e..b35f6a7 100644 --- a/morris.js +++ b/morris.js @@ -1,293 +1,209 @@ +(function() { -/*global jQuery: false, Raphael: false */ + window.Morris = {}; -function parse_year(year) { - var m = year.toString().match(/(\d+) Q(\d)/); - var n = year.toString().match(/(\d+)\-(\d+)/); - if (m) { - return parseInt(m[1], 10) + (parseInt(m[2], 10) * 3 - 1) / 12; - } - else if (n) { - return parseInt(n[1], 10) + (parseInt(n[2], 10) - 1) / 12; - } - else { - return parseInt(year, 10); - } -} + window.Morris.Line = (function() { -function setup_graph(config) { - /*jshint loopfunc: true */ - var data = config.data; - if (data.length === 0) { - return; - } - this.addClass('graph-initialised'); - var xlabels = $.map(data, function (d) { return d[config.xkey]; }); - var series = config.ykeys; - var labels = config.labels; - if (!data || !data.length) { - return; - } - for (var i = 0; i < series.length; i++) { - series[i] = $.map(data, function (d) { return d[series[i]]; }); - } - var xvals = $.map(xlabels, function (x) { return parse_year(x); }); - - var xmin = Math.min.apply(null, xvals); - var xmax = Math.max.apply(null, xvals); - if (xmin === xmax) { - xmin -= 1; - xmax += 1; - } - var ymax = Math.max(20, Math.max.apply(null, - $.map(series, function (s) { return Math.max.apply(null, s); }))); - var r = new Raphael(this[0]); - var margin_top = 25, margin_bottom = 30, margin_right = 25; - var tt = r.text(100, 100, ymax).attr('font-size', 12); - var margin_left = 25 + tt.getBBox().width; - tt.remove(); - var h = this.height() - margin_top - margin_bottom; - var w = this.width() - margin_left - margin_right; - var dx = w / (xmax - xmin); - var dy = h / ymax; - - function trans_x(x) { - if (xvals.length === 1) { - return margin_left + w / 2; + function Line(id, options) { + this.el = $(document.getElementById(id)); + this.options = $.extend(this.defaults, options); + if (this.options.data === void 0 || this.options.data.length === 0) return; + this.el.addClass('graph-initialised'); + this.precalc(); + this.redraw(); } - else { - return margin_left + (x - xmin) * dx; - } - } - function trans_y(y) { - return margin_top + h - y * dy; - } - - // draw horizontal lines - var num_lines = 5; - var line_interval = h / (num_lines - 1); - for (i = 0; i < num_lines; i++) { - var y = margin_top + i * line_interval; - r.text(margin_left - 12, y, Math.floor((num_lines - 1 - i) * ymax / (num_lines - 1))) - .attr('font-size', 12) - .attr('fill', '#888') - .attr('text-anchor', 'end'); - r.path("M" + (margin_left) + "," + y + "L" + (margin_left + w) + "," + y) - .attr('stroke', '#aaa') - .attr('stroke-width', 0.5); - } - // calculate the columns - var cols = $.map(xvals, trans_x); - var hover_margins = $.map(cols.slice(1), - function (x, i) { return (x + cols[i]) / 2; }); + Line.prototype.defaults = { + lineWidth: 3, + pointSize: 4, + lineColors: ['#0b62a4', '#7A92A3', '#4da74d', '#afd8f8', '#edc240', '#cb4b4b', '#9440ed'], + marginTop: 25, + marginRight: 25, + marginBottom: 30, + marginLeft: 25, + numLines: 5, + gridLineColor: '#aaa', + gridTextColor: '#888', + gridTextSize: 12, + gridStrokeWidth: 0.5 + }; - var last_label = null; - var ylabel_margin = 50; - for (i = Math.ceil(xmin); i <= Math.floor(xmax); i++) { - var label = r.text(trans_x(i), margin_top + h + margin_bottom / 2, i) - .attr('font-size', 12) - .attr('fill', '#888'); - if (last_label !== null) { - var bb1 = last_label.getBBox(); - var bb2 = label.getBBox(); - if (bb1.x + bb1.width + ylabel_margin > bb2.x) { - label.remove(); + Line.prototype.precalc = function() { + var all_y_vals, ykey, _i, _len, _ref, + _this = this; + this.xlabels = $.map(this.options.data, function(d) { + return d[_this.options.xkey]; + }); + this.ylabels = this.options.labels; + this.series = []; + _ref = this.options.ykeys; + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + ykey = _ref[_i]; + this.series.push($.map(this.options.data, function(d) { + return d[ykey]; + })); } - else { - last_label = label; + this.xvals = $.map(this.xlabels, function(x) { + return _this.parseYear(x); + }); + this.xmin = Math.min.apply(null, this.xvals); + this.xmax = Math.max.apply(null, this.xvals); + if (this.xmin === this.xmax) { + this.xmin -= 1; + this.xmax += 1; } - } - else { - last_label = label; - } - } + all_y_vals = $.map(this.series, function(x) { + return Math.max.apply(null, x); + }); + return this.ymax = Math.max(20, Math.max.apply(null, all_y_vals)); + }; - // draw the series - var series_points = []; - for (var s = (series.length - 1); s >= 0; s--) { - var path = ''; - var lc = null; - var lg = null; - // translate the coordinates into screen positions - var coords = $.map(series[s], - function (v, idx) { return {x: cols[idx], y: trans_y(v)}; }); - if (coords.length > 1) { - // calculate the gradients - var grads = $.map(coords, function (c, i) { + Line.prototype.redraw = function() { + var c, circle, columns, coords, dx, dy, height, hoverMargins, i, label, labelBox, left, lineInterval, path, prevLabelMargin, s, seriesCoords, seriesPoints, transX, transY, v, width, x, xLabelMargin, y, _i, _j, _len, _len2, _ref, _ref2, _ref3, _ref4, _ref5, _ref6, _ref7, + _this = this; + this.el.empty(); + this.r = new Raphael(this.el[0]); + left = this.measureText(this.ymax, this.options.gridTextSize).width + this.options.marginLeft; + width = this.el.width() - left - this.options.marginRight; + height = this.el.height() - this.options.marginTop - this.options.marginBottom; + dx = width / (this.xmax - this.xmin); + dy = height / this.ymax; + transX = function(x) { + if (_this.xvals.length === 1) { + return left + width / 2; + } else { + return left + (x - _this.xmin) * dx; + } + }; + transY = function(y) { + return _this.options.marginTop + height - y * dy; + }; + lineInterval = height / (this.options.numLines - 1); + for (i = 0, _ref = this.options.numLines - 1; 0 <= _ref ? i <= _ref : i >= _ref; 0 <= _ref ? i++ : i--) { + y = this.options.marginTop + i * lineInterval; + v = Math.round((this.options.numLines - 1 - i) * this.ymax / (this.options.numLines - 1)); + this.r.text(left - this.options.marginLeft / 2, y, v).attr('font-size', this.options.gridTextSize).attr('fill', this.options.gridTextColor).attr('text-anchor', 'end'); + this.r.path("M" + left + "," + y + 'H' + (left + width)).attr('stroke', this.options.gridLineColor).attr('stroke-width', this.options.gridStrokeWidth); + } + prevLabelMargin = null; + xLabelMargin = 50; + for (i = _ref2 = Math.ceil(this.xmin), _ref3 = Math.floor(this.xmax); _ref2 <= _ref3 ? i <= _ref3 : i >= _ref3; _ref2 <= _ref3 ? i++ : i--) { + label = this.r.text(transX(i), this.options.marginTop + height + this.options.marginBottom / 2, i).attr('font-size', this.options.gridTextSize).attr('fill', this.options.gridTextColor); + labelBox = label.getBBox(); + if (prevLabelMargin === null || prevLabelMargin <= labelBox.x) { + prevLabelMargin = labelBox.x + labelBox.width + xLabelMargin; + } else { + label.remove(); + } + } + columns = (function() { + var _i, _len, _ref4, _results; + _ref4 = this.xvals; + _results = []; + for (_i = 0, _len = _ref4.length; _i < _len; _i++) { + x = _ref4[_i]; + _results.push(transX(x)); + } + return _results; + }).call(this); + seriesCoords = []; + _ref4 = this.series; + for (_i = 0, _len = _ref4.length; _i < _len; _i++) { + s = _ref4[_i]; + seriesCoords.push($.map(s, function(y, i) { + return { + x: columns[i], + y: transY(y) + }; + })); + } + for (i = _ref5 = seriesCoords.length - 1; _ref5 <= 0 ? i <= 0 : i >= 0; _ref5 <= 0 ? i++ : i--) { + coords = seriesCoords[i]; + if (coords.length > 1) { + path = this.createPath(coords, this.options.marginTop, left, this.options.marginTop + height, left + width); + this.r.path(path).attr('stroke', this.options.lineColors[i]).attr('stroke-width', this.options.lineWidth); + } + } + seriesPoints = (function() { + var _ref6, _results; + _results = []; + for (i = 0, _ref6 = seriesCoords.length - 1; 0 <= _ref6 ? i <= _ref6 : i >= _ref6; 0 <= _ref6 ? i++ : i--) { + _results.push([]); + } + return _results; + })(); + for (i = _ref6 = seriesCoords.length - 1; _ref6 <= 0 ? i <= 0 : i >= 0; _ref6 <= 0 ? i++ : i--) { + _ref7 = seriesCoords[i]; + for (_j = 0, _len2 = _ref7.length; _j < _len2; _j++) { + c = _ref7[_j]; + circle = this.r.circle(c.x, c.y, this.options.pointSize).attr('fill', this.options.lineColors[i]).attr('stroke-width', 1).attr('stroke', '#ffffff'); + seriesPoints[i].push(circle); + } + } + return hoverMargins = $.map(columns.slice(1), function(x, i) { + return (x + columns[i]) / 2; + }); + }; + + Line.prototype.createPath = function(coords, top, left, bottom, right) { + var c, g, grads, i, ix, lc, lg, path, x1, x2, y1, y2, _ref; + path = ""; + grads = this.gradients(coords); + for (i = 0, _ref = coords.length - 1; 0 <= _ref ? i <= _ref : i >= _ref; 0 <= _ref ? i++ : i--) { + c = coords[i]; + if (i === 0) { + path += "M" + c.x + "," + c.y; + } else { + g = grads[i]; + lc = coords[i - 1]; + lg = grads[i - 1]; + ix = (c.x - lc.x) / 4; + x1 = lc.x + ix; + y1 = Math.min(bottom, lc.y + ix * lg); + x2 = c.x - ix; + y2 = Math.min(bottom, c.y - ix * g); + path += "C" + x1 + "," + y1 + "," + x2 + "," + y2 + "," + c.x + "," + c.y; + } + } + return path; + }; + + Line.prototype.gradients = function(coords) { + return $.map(coords, function(c, i) { if (i === 0) { return (coords[1].y - c.y) / (coords[1].x - c.x); - } - else if (i === xvals.length - 1) { + } else if (i === (coords.length - 1)) { return (c.y - coords[i - 1].y) / (c.x - coords[i - 1].x); - } - else { + } else { return (coords[i + 1].y - coords[i - 1].y) / (coords[i + 1].x - coords[i - 1].x); } }); - for (i = 0; i < coords.length; i++) { - var c = coords[i]; - var g = grads[i]; - if (i === 0) { - path += "M" + ([c.x, c.y].join(',')); - } - else { - var ix = (c.x - lc.x) / 4; - path += "C" + ([lc.x + ix, - Math.min(margin_top + h, lc.y + ix * lg), - c.x - ix, - Math.min(margin_top + h, c.y - ix * g), - c.x, c.y].join(',')); - } - lc = c; - lg = g; - } - r.path(path) - .attr('stroke', config.line_colors[s]) - .attr('stroke-width', config.line_width); - // draw the points - } - series_points.push([]); - for (i = 0; i < series[s].length; i++) { - var c1 = {x: cols[i], y: trans_y(series[s][i])}; - var circle = r.circle(c1.x, c1.y, config.point_size) - .attr('fill', config.line_colors[s]) - .attr('stroke-width', 1) - .attr('stroke', '#ffffff'); - series_points[series_points.length - 1].push(circle); - } - } - - // hover labels - var label_height = 12; - var label_padding_x = 10; - var label_padding_y = 5; - var label_margin = 10; - var yvar_labels = []; - var label_float_height = (label_height * 1.5) * (series.length + 1); - var label_float = r.rect(-10, -label_float_height / 2 - label_padding_y, 20, label_float_height + label_padding_y * 2, 10) - .attr('fill', '#fff') - .attr('stroke', '#ccc') - .attr('stroke-width', 2) - .attr('opacity', 0.95); - var xvar_label = r.text(0, (label_height * 0.75) - (label_float_height / 2), '') - .attr('fill', '#444') - .attr('font-weight', 'bold') - .attr('font-size', label_height); - var label_set = r.set(); - label_set.push(label_float); - label_set.push(xvar_label); - for (i = 0; i < series.length; i++) { - var yl = r.text(0, (label_height * 1.5 * (i + 1.5)) - (label_float_height / 2), '') - .attr('fill', config.line_colors[i]) - .attr('font-size', label_height); - yvar_labels.push(yl); - label_set.push(yl); - } - function commas(v) { - v = v.toString(); - var r = ""; - while (v.length > 3) { - r = "," + v.substr(v.length - 3) + r; - v = v.substr(0, v.length - 3); - } - r = v + r; - return r; - } - function update_float(index) { - label_set.show(); - xvar_label.attr('text', xlabels[index]); - for (var i = 0; i < series.length; i++) { - yvar_labels[i].attr('text', labels[i] + ': ' + commas(series[i][index])); - } - // calculate bbox width - var bbw = Math.max(xvar_label.getBBox().width, - Math.max.apply(null, $.map(yvar_labels, function (l) { return l.getBBox().width; }))); - label_float.attr('width', bbw + label_padding_x * 2); - label_float.attr('x', -label_padding_x - bbw / 2); - // determine y-pos - var yloc = Math.min.apply(null, $.map(series, function (s) { return trans_y(s[index]); })); - if (yloc > label_float_height + label_padding_y * 2 + label_margin + margin_top) { - yloc = yloc - label_float_height / 2 - label_padding_y - label_margin; - } - else { - yloc = yloc + label_float_height / 2 + label_padding_y + label_margin; - } - yloc = Math.max(margin_top + label_float_height / 2 + label_padding_y, yloc); - yloc = Math.min(margin_top + h - label_float_height / 2 - label_padding_y, yloc); - var xloc = Math.min(margin_left + w - bbw / 2 - label_padding_y, cols[index]); - xloc = Math.max(margin_left + bbw / 2 + label_padding_x, xloc); - label_set.attr('transform', 't' + xloc + ',' + yloc); - } - function hide_float() { - label_set.hide(); - } - - // column hilighting - var self = this; - var prev_hilight = null; - var point_grow = Raphael.animation({r: config.point_size + 3}, 25, "linear"); - var point_shrink = Raphael.animation({r: config.point_size}, 25, "linear"); - function highlight(index) { - var j; - if (prev_hilight !== null && prev_hilight !== index) { - for (j = 0; j < series_points.length; j++) { - series_points[j][prev_hilight].animate(point_shrink); - } - } - if (index !== null && prev_hilight !== index) { - for (j = 0; j < series_points.length; j++) { - series_points[j][index].animate(point_grow); - } - update_float(index); - } - prev_hilight = index; - if (index === null) { - hide_float(); - } - } - function update_hilight(x_coord) { - var x = x_coord - self.offset().left; - for (var i = hover_margins.length; i > 0; i--) { - if (hover_margins[i - 1] > x) { - break; - } - } - highlight(i); - } - this.mousemove(function (evt) { - update_hilight(evt.pageX); - }); - function touchhandler(evt) { - var touch = evt.originalEvent.touches[0] || - evt.originalEvent.changedTouches[0]; - update_hilight(touch.pageX); - return touch; - } - this.bind('touchstart', touchhandler); - this.bind('touchmove', touchhandler); - this.bind('touchend', touchhandler); - highlight(0); -} + }; -$.fn.hml = function (options) { - var config = { - line_width: 3, - point_size: 4, - line_colors: [ - '#0b62a4', - '#7A92A3', - '#4da74d', - '#afd8f8', - '#edc240', - '#cb4b4b', - '#9440ed' - ] - }; - if (options) { - $.extend(config, options); - } - return this.each(function () { - setup_graph.call($(this), config); - }); -}; + Line.prototype.measureText = function(text, fontSize) { + var ret, tt; + if (fontSize == null) fontSize = 12; + tt = this.r.text(100, 100, text).attr('font-size', fontSize); + ret = tt.getBBox(); + tt.remove(); + return ret; + }; + + Line.prototype.parseYear = function(year) { + var m, n; + m = year.toString().match(/(\d+) Q(\d)/); + n = year.toString().match(/(\d+)\-(\d+)/); + if (m) { + return parseInt(m[1], 10) + (parseInt(m[2], 10) * 3 - 1) / 12; + } else if (n) { + return parseInt(n[1], 10) + (parseInt(n[2], 10) - 1) / 12; + } else { + return parseInt(year, 10); + } + }; + + return Line; + + })(); + +}).call(this);