Refactor.

- Test paths as rendered in SVG.
- More exact unit tests for createPath.
- Catch some more edge case bugs in createPath.
- Refactor createPath to handle null values better.
This commit is contained in:
Olly Smith 2012-12-03 08:39:13 +00:00
parent b0e99f7ca9
commit d41bea2e23
5 changed files with 166 additions and 146 deletions

View File

@ -95,7 +95,7 @@ class Morris.Grid extends Morris.EventEmitter
ret.y = for ykey, idx in @options.ykeys
yval = row[ykey]
yval = parseFloat(yval) if typeof yval is 'string'
yval = null unless typeof yval is 'number'
yval = null if yval? and typeof yval isnt 'number'
if yval?
if @cumulative
total += yval

View File

@ -71,7 +71,7 @@ class Morris.Line extends Morris.Grid
for row in @data
row._x = @transX(row.x)
row._y = for y in row.y
if y? then @transY(y) else null
if y? then @transY(y) else y
# calculate hover margins
#
@ -85,11 +85,11 @@ class Morris.Line extends Morris.Grid
generatePaths: ->
@paths = for i in [0...@options.ykeys.length]
smooth = @options.smooth is true or @options.ykeys[i] in @options.smooth
coords = ({x: r._x, y: r._y[i]} for r in @data )
coords = (c for c in coords when c.y != null) if @options.continuousLine
coords = ({x: r._x, y: r._y[i]} for r in @data when r._y[i] isnt undefined)
coords = (c for c in coords when c.y isnt null) if @options.continuousLine
if coords.length > 1
@createPath coords, smooth
Morris.Line.createPath coords, smooth, @bottom
else
null
@ -160,54 +160,48 @@ class Morris.Line extends Morris.Grid
# create a path for a data series
#
# @private
createPath: (coords, smooth) ->
@createPath: (coords, smooth, bottom) ->
path = ""
grads = @gradients coords if smooth
grads = Morris.Line.gradients(coords) if smooth
nextPathType = "M"
for i in [0..coords.length-1]
c = coords[i]
if c.y == null
nextPathType = "M"
continue
if nextPathType == "M"
path += "M#{c.x},#{c.y}"
nextPathType = "CorL"
else
if smooth
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}"
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.max(bottom, prevCoord.y + ix * lg)
x2 = coord.x - ix
y2 = Math.max(bottom, coord.y - ix * g)
path += "C#{x1},#{y1},#{x2},#{y2},#{coord.x},#{coord.y}"
else
path += "L#{coord.x},#{coord.y}"
else
path += "L#{c.x},#{c.y}"
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) ->
coordA = null
coordB = null
for c, i in coords
if i is 0
coordA = coords[1]
coordB = c
else if i is (coords.length - 1)
coordA = c
coordB = coords[i - 1]
else
coordA = coords[i + 1]
coordB = coords[i - 1]
if coordA.y != null and coordB.y != null and coordA.x != null and coordB.x != null
(coordA.y - coordB.y) / (coordA.x - coordB.x)
@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

110
morris.js
View File

@ -155,7 +155,7 @@
if (typeof yval === 'string') {
yval = parseFloat(yval);
}
if (typeof yval !== 'number') {
if ((yval != null) && typeof yval !== 'number') {
yval = null;
}
if (yval != null) {
@ -484,7 +484,8 @@
smooth: true,
hideHover: false,
xLabels: 'auto',
xLabelFormat: null
xLabelFormat: null,
continuousLine: true
};
Line.prototype.calc = function() {
@ -509,7 +510,7 @@
if (y != null) {
_results1.push(this.transY(y));
} else {
_results1.push(null);
_results1.push(y);
}
}
return _results1;
@ -533,7 +534,7 @@
};
Line.prototype.generatePaths = function() {
var coords, i, r, smooth;
var c, coords, i, r, smooth;
return this.paths = (function() {
var _i, _ref, _ref1, _results;
_results = [];
@ -545,7 +546,7 @@
_results1 = [];
for (_j = 0, _len = _ref2.length; _j < _len; _j++) {
r = _ref2[_j];
if (r._y[i] !== null) {
if (r._y[i] !== void 0) {
_results1.push({
x: r._x,
y: r._y[i]
@ -554,8 +555,21 @@
}
return _results1;
}).call(this);
if (this.options.continuousLine) {
coords = (function() {
var _j, _len, _results1;
_results1 = [];
for (_j = 0, _len = coords.length; _j < _len; _j++) {
c = coords[_j];
if (c.y !== null) {
_results1.push(c);
}
}
return _results1;
})();
}
if (coords.length > 1) {
_results.push(this.createPath(coords, smooth));
_results.push(Morris.Line.createPath(coords, smooth, this.bottom));
} else {
_results.push(null);
}
@ -651,52 +665,68 @@
return _results;
};
Line.prototype.createPath = function(coords, smooth) {
var c, g, grads, i, ix, lc, lg, path, x1, x2, y1, y2, _i, _ref;
Line.createPath = function(coords, smooth, bottom) {
var coord, g, grads, i, ix, lg, path, prevCoord, x1, x2, y1, y2, _i, _len;
path = "";
if (smooth) {
grads = this.gradients(coords);
for (i = _i = 0, _ref = coords.length - 1; 0 <= _ref ? _i <= _ref : _i >= _ref; i = 0 <= _ref ? ++_i : --_i) {
c = coords[i];
if (i === 0) {
path += "M" + c.x + "," + c.y;
grads = Morris.Line.gradients(coords);
}
prevCoord = {
y: null
};
for (i = _i = 0, _len = coords.length; _i < _len; i = ++_i) {
coord = coords[i];
if (coord.y != null) {
if (prevCoord.y != null) {
if (smooth) {
g = grads[i];
lg = grads[i - 1];
ix = (coord.x - prevCoord.x) / 4;
x1 = prevCoord.x + ix;
y1 = Math.max(bottom, prevCoord.y + ix * lg);
x2 = coord.x - ix;
y2 = Math.max(bottom, coord.y - ix * g);
path += "C" + x1 + "," + y1 + "," + x2 + "," + y2 + "," + coord.x + "," + coord.y;
} else {
path += "L" + coord.x + "," + coord.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(this.bottom, lc.y + ix * lg);
x2 = c.x - ix;
y2 = Math.min(this.bottom, c.y - ix * g);
path += "C" + x1 + "," + y1 + "," + x2 + "," + y2 + "," + c.x + "," + c.y;
if (!smooth || (grads[i] != null)) {
path += "M" + coord.x + "," + coord.y;
}
}
}
} else {
path = "M" + ((function() {
var _j, _len, _results;
_results = [];
for (_j = 0, _len = coords.length; _j < _len; _j++) {
c = coords[_j];
_results.push("" + c.x + "," + c.y);
}
return _results;
})()).join("L");
prevCoord = coord;
}
return path;
};
Line.prototype.gradients = function(coords) {
var c, i, _i, _len, _results;
Line.gradients = function(coords) {
var coord, grad, i, nextCoord, prevCoord, _i, _len, _results;
grad = function(a, b) {
return (a.y - b.y) / (a.x - b.x);
};
_results = [];
for (i = _i = 0, _len = coords.length; _i < _len; i = ++_i) {
c = coords[i];
if (i === 0) {
_results.push((coords[1].y - c.y) / (coords[1].x - c.x));
} else if (i === (coords.length - 1)) {
_results.push((c.y - coords[i - 1].y) / (c.x - coords[i - 1].x));
coord = coords[i];
if (coord.y != null) {
nextCoord = coords[i + 1] || {
y: null
};
prevCoord = coords[i - 1] || {
y: null
};
if ((prevCoord.y != null) && (nextCoord.y != null)) {
_results.push(grad(prevCoord, nextCoord));
} else if (prevCoord.y != null) {
_results.push(grad(prevCoord, coord));
} else if (nextCoord.y != null) {
_results.push(grad(coord, nextCoord));
} else {
_results.push(null);
}
} else {
_results.push((coords[i + 1].y - coords[i - 1].y) / (coords[i + 1].x - coords[i - 1].x));
_results.push(null);
}
}
return _results;

2
morris.min.js vendored

File diff suppressed because one or more lines are too long

View File

@ -68,80 +68,76 @@ describe 'Morris.Line', ->
"#{x.getYear()}/#{x.getMonth()+1}/#{x.getDay()}"
chart.data.map((x) -> x.label).should == ['2012/1/1', '2012/1/2']
describe '#generatePaths', ->
TestDefaults = {}
describe 'rendering lines', ->
beforeEach ->
TestDefaults = {element: 'graph', xkey: 'x', ykeys: ['y'], labels: ['dontcare']}
@defaults =
element: 'graph'
data: [{x:0, y:1, z:0}, {x:1, y:0, z:1}, {x:2, y:1, z:0}, {x:3, y:0, z:1}, {x:4, y:1, z:0}]
xkey: 'x'
ykeys: ['y', 'z']
labels: ['y', 'z']
lineColors: ['#abcdef', '#fedcba']
smooth: true
continuousLine: false
shouldHavePath = (regex, color = '#abcdef') ->
# Matches an SVG path element within the rendered chart.
#
# Sneakily uses line colors to differentiate between paths within
# the chart.
$('#graph').find("path[stroke='#{color}']").attr('d').should.match regex
it 'should generate smooth lines when options.smooth is true', ->
testData = [{x: 1, y: 1}, {x: 3, y: 1 }]
chart = Morris.Line(TestDefaults extends {data: testData, continuousLine: true})
path = chart.generatePaths()[0]
path.match(/[A-Z]/g).should.deep.equal ['M', 'C']
Morris.Line @defaults
shouldHavePath /M[\d\.]+,[\d\.]+(C[\d\.]+(,[\d\.]+){5}){4}/
it 'should generate jagged, continuous lines when options.smooth is false and options.continuousLine is true', ->
testData = [{x: 1, y: 1}, {x: 2, y: null }, {x: 3, y: 1}]
chart = Morris.Line(TestDefaults extends {data: testData, smooth: false, continuousLine: true})
path = chart.generatePaths()[0]
path.match(/[A-Z]/g).should.deep.equal ['M', 'L']
it 'should generate jagged, discontinuous lines when options.smooth is false and options.continuousLine is false', ->
testData = [{x: 1, y: 1}, {x: 2, y: null }, {x: 3, y: 1}, {x: 4, y: 1}]
chart = Morris.Line(TestDefaults extends {data: testData, smooth: false, continuousLine: false})
path = chart.generatePaths()[0]
path.match(/[A-Z]/g).should.deep.equal ['M', 'M', 'L']
it 'should generate jagged lines when options.smooth is false', ->
Morris.Line $.extend(@defaults, smooth: false)
shouldHavePath /M[\d\.]+,[\d\.]+(L[\d\.]+,[\d\.]+){4}/
it 'should generate smooth/jagged lines according to the value for each series when options.smooth is an array', ->
testData = [{x: 1, a: 1, b: 1}, {x: 3, a: 1, b: 1}]
chart = Morris.Line(TestDefaults extends {data: testData, smooth: ['a'], ykeys: ['a', 'b']})
pathA = chart.generatePaths()[0]
pathA.match(/[A-Z]/g).should.deep.equal ['M', 'C']
Morris.Line $.extend(@defaults, smooth: ['y'])
shouldHavePath /M[\d\.]+,[\d\.]+(C[\d\.]+(,[\d\.]+){5}){4}/, '#abcdef'
shouldHavePath /M[\d\.]+,[\d\.]+(L[\d\.]+,[\d\.]+){4}/, '#fedcba'
pathB = chart.generatePaths()[1]
pathB.match(/[A-Z]/g).should.deep.equal ['M', 'L']
it 'should ignore undefined values', ->
@defaults.data[2].y = undefined
Morris.Line @defaults
shouldHavePath /M[\d\.]+,[\d\.]+(C[\d\.]+(,[\d\.]+){5}){3}/
#skipping because undefined values are converted to nulls in the setData method morris.grid line#98
it.skip 'should filter undefined values from series', ->
testData = [{x: 1, y: 1}, {x: 2, y: undefined}, {x: 3, y: 1}]
options =
data: testData
continuousLine: false #doesn't matter for undefined values
it 'should ignore null values when options.continuousLine is true', ->
@defaults.data[2].y = null
Morris.Line $.extend(@defaults, continuousLine: true)
shouldHavePath /M[\d\.]+,[\d\.]+(C[\d\.]+(,[\d\.]+){5}){3}/
chart = Morris.Line(TestDefaults extends options)
path = chart.generatePaths()[0]
path.match(/[A-Z]/g).should.deep.equal ['M', 'C']
it 'should filter null values from series only when options.continuousLine is true', ->
testData = [{x: 1, y: 1}, {x: 2, y: null}, {x: 3, y: 1}]
chart = Morris.Line(TestDefaults extends {data: testData, continuousLine: true})
path = chart.generatePaths()[0]
path.match(/[A-Z]/g).should.deep.equal ['M', 'C']
it 'should not filter null values from series when options.continuousLine is false', ->
testData = [{x: 1, y: 1}, {x: 2, y: null}, {x: 3, y: 1}, {x: 4, y: 1}]
chart = Morris.Line(TestDefaults extends {data: testData, continuousLine: false})
path = chart.generatePaths()[0]
path.match(/[A-Z]/g).should.deep.equal ['M', 'M', 'C']
it 'should break the line at null values when options.continuousLine is false', ->
@defaults.data[2].y = null
Morris.Line @defaults
shouldHavePath /(M[\d\.]+,[\d\.]+C[\d\.]+(,[\d\.]+){5}){2}/
describe '#createPath', ->
TestDefaults = {}
beforeEach ->
TestDefaults = {element: 'graph', xkey: 'x', ykeys: ['y'], labels: ['dontcare']}
it 'should generate a smooth line', ->
testData = [{x: 1, y: 1}, {x: 3, y: 1}]
chart = Morris.Line(TestDefaults extends {data: testData})
path = chart.createPath(testData, true)
path.match(/[A-Z]/g).should.deep.equal ['M', 'C']
testData = [{x: 0, y: 10}, {x: 10, y: 0}, {x: 20, y: 10}]
path = Morris.Line.createPath(testData, true, 0)
path.should.equal 'M0,10C2.5,7.5,7.5,0,10,0C12.5,0,17.5,7.5,20,10'
it 'should generate a jagged line', ->
testData = [{x: 1, y: 1}, {x: 3, y: 1}]
chart = Morris.Line(TestDefaults extends {data: testData})
path = chart.createPath(testData, false)
path.match(/[A-Z]/g).should.deep.equal ['M', 'L']
testData = [{x: 0, y: 10}, {x: 10, y: 0}, {x: 20, y: 10}]
path = Morris.Line.createPath(testData, false, 0)
path.should.equal 'M0,10L10,0L20,10'
it 'should prevent paths from descending below the bottom of the chart', ->
testData = [{x: 0, y: 20}, {x: 10, y: 10}, {x: 20, y: 30}]
path = Morris.Line.createPath(testData, true, 10)
path.should.equal 'M0,20C2.5,17.5,7.5,10,10,10C12.5,11.25,17.5,25,20,30'
it 'should break the line at null values', ->
testData = [{x: 1, y: 1}, {x: 2, y: null}, {x: 3, y: 1}, {x: 4, y: 1}]
chart = Morris.Line(TestDefaults extends {data: testData})
path = chart.createPath(testData, true)
path.match(/[A-Z]/g).should.deep.equal ['M', 'M', 'C']
testData = [{x: 0, y: 10}, {x: 10, y: 0}, {x: 20, y: null}, {x: 30, y: 10}, {x: 40, y: 0}]
path = Morris.Line.createPath(testData, true, 0)
path.should.equal 'M0,10C2.5,7.5,7.5,2.5,10,0M30,10C32.5,7.5,37.5,2.5,40,0'
it 'should ignore leading and trailing null values', ->
testData = [{x: 0, y: null}, {x: 10, y: 10}, {x: 20, y: 0}, {x: 30, y: 10}, {x: 40, y: null}]
path = Morris.Line.createPath(testData, true, 0)
path.should.equal 'M10,10C12.5,7.5,17.5,0,20,0C22.5,0,27.5,7.5,30,10'