diff --git a/src/components/annotations/annotation_defaults.js b/src/components/annotations/annotation_defaults.js index 15aed2c04e1..35673c06ae6 100644 --- a/src/components/annotations/annotation_defaults.js +++ b/src/components/annotations/annotation_defaults.js @@ -39,69 +39,56 @@ module.exports = function handleAnnotationDefaults(annIn, fullLayout) { var borderWidth = coerce('borderwidth'); var showArrow = coerce('showarrow'); - if(showArrow) { - coerce('arrowcolor', borderOpacity ? annOut.bordercolor : Color.defaultLine); - coerce('arrowhead'); - coerce('arrowsize'); - coerce('arrowwidth', ((borderOpacity && borderWidth) || 1) * 2); - coerce('ax'); - coerce('ay'); - coerce('axref'); - coerce('ayref'); - - // if you have one part of arrow length you should have both - Lib.noneOrAll(annIn, annOut, ['ax', 'ay']); - } - coerce('text', showArrow ? ' ' : 'new text'); coerce('textangle'); Lib.coerceFont(coerce, 'font', fullLayout.font); // positioning - var axLetters = ['x', 'y']; + var axLetters = ['x', 'y'], + arrowPosDflt = [-10, -30], + gdMock = {_fullLayout: fullLayout}; for(var i = 0; i < 2; i++) { - var axLetter = axLetters[i], - tdMock = {_fullLayout: fullLayout}; + var axLetter = axLetters[i]; // xref, yref - var axRef = Axes.coerceRef(annIn, annOut, tdMock, axLetter); - - // TODO: should be refactored in conjunction with Axes axref, ayref - var aaxRef = Axes.coerceARef(annIn, annOut, tdMock, axLetter); + var axRef = Axes.coerceRef(annIn, annOut, gdMock, axLetter, '', 'paper'); // x, y - var defaultPosition = 0.5; - if(axRef !== 'paper') { - var ax = Axes.getFromId(tdMock, axRef); - defaultPosition = ax.range[0] + defaultPosition * (ax.range[1] - ax.range[0]); - - // convert date or category strings to numbers - if(['date', 'category'].indexOf(ax.type) !== -1 && - typeof annIn[axLetter] === 'string') { - var newval; - if(ax.type === 'date') { - newval = Lib.dateTime2ms(annIn[axLetter]); - if(newval !== false) annIn[axLetter] = newval; - - if(aaxRef === axRef) { - var newvalB = Lib.dateTime2ms(annIn['a' + axLetter]); - if(newvalB !== false) annIn['a' + axLetter] = newvalB; - } - } - else if((ax._categories || []).length) { - newval = ax._categories.indexOf(annIn[axLetter]); - if(newval !== -1) annIn[axLetter] = newval; - } + Axes.coercePosition(annOut, gdMock, coerce, axRef, axLetter, 0.5); + + if(showArrow) { + var arrowPosAttr = 'a' + axLetter, + // axref, ayref + aaxRef = Axes.coerceRef(annIn, annOut, gdMock, arrowPosAttr, 'pixel'); + + // for now the arrow can only be on the same axis or specified as pixels + // TODO: sometime it might be interesting to allow it to be on *any* axis + // but that would require updates to drawing & autorange code and maybe more + if(aaxRef !== 'pixel' && aaxRef !== axRef) { + aaxRef = annOut[arrowPosAttr] = 'pixel'; } + + // ax, ay + var aDflt = (aaxRef === 'pixel') ? arrowPosDflt[i] : 0.4; + Axes.coercePosition(annOut, gdMock, coerce, aaxRef, arrowPosAttr, aDflt); } - coerce(axLetter, defaultPosition); // xanchor, yanchor - if(!showArrow) coerce(axLetter + 'anchor'); + else coerce(axLetter + 'anchor'); } // if you have one coordinate you should have both Lib.noneOrAll(annIn, annOut, ['x', 'y']); + if(showArrow) { + coerce('arrowcolor', borderOpacity ? annOut.bordercolor : Color.defaultLine); + coerce('arrowhead'); + coerce('arrowsize'); + coerce('arrowwidth', ((borderOpacity && borderWidth) || 1) * 2); + + // if you have one part of arrow length you should have both + Lib.noneOrAll(annIn, annOut, ['ax', 'ay']); + } + return annOut; }; diff --git a/src/components/annotations/attributes.js b/src/components/annotations/attributes.js index 4c9b5f12024..72484a1e2f9 100644 --- a/src/components/annotations/attributes.js +++ b/src/components/annotations/attributes.js @@ -141,27 +141,27 @@ module.exports = { description: 'Sets the width (in px) of annotation arrow.' }, ax: { - valType: 'number', - dflt: -10, + valType: 'any', role: 'info', description: [ 'Sets the x component of the arrow tail about the arrow head.', 'If `axref` is `pixel`, a positive (negative) ', 'component corresponds to an arrow pointing', 'from right to left (left to right).', - 'If `axref` is an axis, this is a value on that axis.' + 'If `axref` is an axis, this is an absolute value on that axis,', + 'like `x`, NOT a relative value.' ].join(' ') }, ay: { - valType: 'number', - dflt: -30, + valType: 'any', role: 'info', description: [ 'Sets the y component of the arrow tail about the arrow head.', 'If `ayref` is `pixel`, a positive (negative) ', 'component corresponds to an arrow pointing', 'from bottom to top (top to bottom).', - 'If `ayref` is an axis, this is a value on that axis.' + 'If `ayref` is an axis, this is an absolute value on that axis,', + 'like `y`, NOT a relative value.' ].join(' ') }, axref: { @@ -216,11 +216,18 @@ module.exports = { ].join(' ') }, x: { - valType: 'number', + valType: 'any', role: 'info', description: [ 'Sets the annotation\'s x position.', - 'Note that dates and categories are converted to numbers.' + 'If the axis `type` is *log*, then you must take the', + 'log of your desired range.', + 'If the axis `type` is *date*, it should be date strings,', + 'like date data, though Date objects and unix milliseconds', + 'will be accepted and converted to strings.', + 'If the axis `type` is *category*, it should be numbers,', + 'using the scale where each category is assigned a serial', + 'number from zero in the order it appears.' ].join(' ') }, xanchor: { @@ -259,11 +266,18 @@ module.exports = { ].join(' ') }, y: { - valType: 'number', + valType: 'any', role: 'info', description: [ 'Sets the annotation\'s y position.', - 'Note that dates and categories are converted to numbers.' + 'If the axis `type` is *log*, then you must take the', + 'log of your desired range.', + 'If the axis `type` is *date*, it should be date strings,', + 'like date data, though Date objects and unix milliseconds', + 'will be accepted and converted to strings.', + 'If the axis `type` is *category*, it should be numbers,', + 'using the scale where each category is assigned a serial', + 'number from zero in the order it appears.' ].join(' ') }, yanchor: { diff --git a/src/components/annotations/calc_autorange.js b/src/components/annotations/calc_autorange.js index 99abb765c99..a5969ae181b 100644 --- a/src/components/annotations/calc_autorange.js +++ b/src/components/annotations/calc_autorange.js @@ -69,14 +69,14 @@ function annAutorange(gd) { } if(xa && xa.autorange) { - Axes.expand(xa, [xa.l2c(ann.x)], { + Axes.expand(xa, [xa.l2c(xa.r2l(ann.x))], { ppadplus: rightSize, ppadminus: leftSize }); } if(ya && ya.autorange) { - Axes.expand(ya, [ya.l2c(ann.y)], { + Axes.expand(ya, [ya.l2c(ya.r2l(ann.y))], { ppadplus: bottomSize, ppadminus: topSize }); diff --git a/src/components/annotations/draw.js b/src/components/annotations/draw.js index 92b2734ac73..ce70706fc1f 100644 --- a/src/components/annotations/draw.js +++ b/src/components/annotations/draw.js @@ -172,12 +172,19 @@ function drawOne(gd, index, opt, value) { continue; } - var axOld = Axes.getFromId(gd, Axes.coerceRef(oldRef, {}, gd, axLetter)), - axNew = Axes.getFromId(gd, Axes.coerceRef(optionsIn, {}, gd, axLetter)), + var axOld = Axes.getFromId(gd, Axes.coerceRef(oldRef, {}, gd, axLetter, '', 'paper')), + axNew = Axes.getFromId(gd, Axes.coerceRef(optionsIn, {}, gd, axLetter, '', 'paper')), position = optionsIn[axLetter], axTypeOld = oldPrivate['_' + axLetter + 'type']; if(optionsEdit[axLetter + 'ref'] !== undefined) { + + // TODO: include ax / ay / axref / ayref here if not 'pixel' + // or even better, move all of this machinery out of here and into + // streambed as extra attributes to a regular relayout call + // we should do this after v2.0 when it can work equivalently for + // annotations, shapes, and images. + var autoAnchor = optionsIn[axLetter + 'anchor'] === 'auto', plotSize = (axLetter === 'x' ? gs.w : gs.h), halfSizeFrac = (oldPrivate['_' + axLetter + 'size'] || 0) / @@ -186,18 +193,11 @@ function drawOne(gd, index, opt, value) { // go to the same fraction of the axis length // whether or not these axes share a domain - // first convert to fraction of the axis - position = (position - axOld.range[0]) / - (axOld.range[1] - axOld.range[0]); - - // then convert to new data coordinates at the same fraction - position = axNew.range[0] + - position * (axNew.range[1] - axNew.range[0]); + position = axNew.fraction2r(axOld.r2fraction(position)); } else if(axOld) { // data -> paper // first convert to fraction of the axis - position = (position - axOld.range[0]) / - (axOld.range[1] - axOld.range[0]); + position = axOld.r2fraction(position); // next scale the axis to the whole plot position = axOld.domain[0] + @@ -225,8 +225,7 @@ function drawOne(gd, index, opt, value) { (axNew.domain[1] - axNew.domain[0]); // finally convert to data coordinates - position = axNew.range[0] + - position * (axNew.range[1] - axNew.range[0]); + position = axNew.fraction2r(position); } } @@ -357,20 +356,21 @@ function drawOne(gd, index, opt, value) { // outside the visible plot (as long as the axis // isn't autoranged - then we need to draw it // anyway to get its bounding box) - if(!ax.autorange && ((options[axLetter] - ax.range[0]) * - (options[axLetter] - ax.range[1]) > 0)) { + var posFraction = ax.r2fraction(options[axLetter]); + if(!ax.autorange && (posFraction < 0 || posFraction > 1)) { if(options['a' + axLetter + 'ref'] === axRef) { - if((options['a' + axLetter] - ax.range[0]) * - (options['a' + axLetter] - ax.range[1]) > 0) { + posFraction = ax.r2fraction(options['a' + axLetter]); + if(posFraction < 0 || posFraction > 1) { annotationIsOffscreen = true; } - } else { + } + else { annotationIsOffscreen = true; } if(annotationIsOffscreen) return; } - annPosPx[axLetter] = ax._offset + ax.l2p(options[axLetter]); + annPosPx[axLetter] = ax._offset + ax.r2p(options[axLetter]); alignPosition = 0.5; } else { @@ -383,7 +383,7 @@ function drawOne(gd, index, opt, value) { var alignShift = 0; if(options['a' + axLetter + 'ref'] === axRef) { - annPosPx['aa' + axLetter] = ax._offset + ax.l2p(options['a' + axLetter]); + annPosPx['aa' + axLetter] = ax._offset + ax.r2p(options['a' + axLetter]); } else { if(options.showarrow) { alignShift = options['a' + axLetter]; @@ -583,22 +583,22 @@ function drawOne(gd, index, opt, value) { ann.call(Lib.setTranslate, xcenter, ycenter); update[annbase + '.x'] = xa ? - (options.x + dx / xa._m) : + xa.p2r(xa.r2p(options.x) + dx) : ((arrowX + dx - gs.l) / gs.w); update[annbase + '.y'] = ya ? - (options.y + dy / ya._m) : + ya.p2r(ya.r2p(options.y) + dy) : (1 - ((arrowY + dy - gs.t) / gs.h)); if(options.axref === options.xref) { update[annbase + '.ax'] = xa ? - (options.ax + dx / xa._m) : - ((arrowX + dx - gs.l) / gs.w); + xa.p2r(xa.r2p(options.ax) + dx) : + ((arrowX + dx - gs.l) / gs.w); } if(options.ayref === options.yref) { update[annbase + '.ay'] = ya ? - (options.ay + dy / ya._m) : - (1 - ((arrowY + dy - gs.t) / gs.h)); + ya.p2r(ya.r2p(options.ay) + dy) : + (1 - ((arrowY + dy - gs.t) / gs.h)); } anng.attr({ @@ -644,13 +644,13 @@ function drawOne(gd, index, opt, value) { var csr = 'pointer'; if(options.showarrow) { if(options.axref === options.xref) { - update[annbase + '.ax'] = xa.p2l(xa.l2p(options.ax) + dx); + update[annbase + '.ax'] = xa.p2r(xa.r2p(options.ax) + dx); } else { update[annbase + '.ax'] = options.ax + dx; } if(options.ayref === options.yref) { - update[annbase + '.ay'] = ya.p2l(ya.l2p(options.ay) + dy); + update[annbase + '.ay'] = ya.p2r(ya.r2p(options.ay) + dy); } else { update[annbase + '.ay'] = options.ay + dy; } diff --git a/src/components/drawing/index.js b/src/components/drawing/index.js index 34e2572bc8d..f5203e48ddb 100644 --- a/src/components/drawing/index.js +++ b/src/components/drawing/index.js @@ -75,14 +75,14 @@ drawing.getPx = function(s, styleAttr) { return Number(s.style(styleAttr).replace(/px$/, '')); }; -drawing.crispRound = function(td, lineWidth, dflt) { +drawing.crispRound = function(gd, lineWidth, dflt) { // for lines that disable antialiasing we want to // make sure the width is an integer, and at least 1 if it's nonzero if(!lineWidth || !isNumeric(lineWidth)) return dflt || 0; // but not for static plots - these don't get antialiased anyway. - if(td._context.staticPlot) return lineWidth; + if(gd._context.staticPlot) return lineWidth; if(lineWidth < 1) return 1; return Math.round(lineWidth); diff --git a/src/components/images/attributes.js b/src/components/images/attributes.js index 02d04a78297..89a18d1c303 100644 --- a/src/components/images/attributes.js +++ b/src/components/images/attributes.js @@ -90,7 +90,7 @@ module.exports = { }, x: { - valType: 'number', + valType: 'any', role: 'info', dflt: 0, description: [ @@ -102,7 +102,7 @@ module.exports = { }, y: { - valType: 'number', + valType: 'any', role: 'info', dflt: 0, description: [ diff --git a/src/components/images/draw.js b/src/components/images/draw.js index b5ad8021558..bf570357dd2 100644 --- a/src/components/images/draw.js +++ b/src/components/images/draw.js @@ -99,12 +99,12 @@ module.exports = function draw(gd) { var thisImage = d3.select(this); // Axes if specified - var xref = Axes.getFromId(gd, d.xref), - yref = Axes.getFromId(gd, d.yref); + var xa = Axes.getFromId(gd, d.xref), + ya = Axes.getFromId(gd, d.yref); var size = fullLayout._size, - width = xref ? Math.abs(xref.l2p(d.sizex) - xref.l2p(0)) : d.sizex * size.w, - height = yref ? Math.abs(yref.l2p(d.sizey) - yref.l2p(0)) : d.sizey * size.h; + width = xa ? Math.abs(xa.l2p(d.sizex) - xa.l2p(0)) : d.sizex * size.w, + height = ya ? Math.abs(ya.l2p(d.sizey) - ya.l2p(0)) : d.sizey * size.h; // Offsets for anchor positioning var xOffset = width * anchors.x[d.xanchor].offset, @@ -113,8 +113,8 @@ module.exports = function draw(gd) { var sizing = anchors.x[d.xanchor].sizing + anchors.y[d.yanchor].sizing; // Final positions - var xPos = (xref ? xref.l2p(d.x) + xref._offset : d.x * size.w + size.l) + xOffset, - yPos = (yref ? yref.l2p(d.y) + yref._offset : size.h - d.y * size.h + size.t) + yOffset; + var xPos = (xa ? xa.r2p(d.x) + xa._offset : d.x * size.w + size.l) + xOffset, + yPos = (ya ? ya.r2p(d.y) + ya._offset : size.h - d.y * size.h + size.t) + yOffset; // Construct the proper aspectRatio attribute @@ -139,8 +139,8 @@ module.exports = function draw(gd) { // Set proper clipping on images - var xId = xref ? xref._id : '', - yId = yref ? yref._id : '', + var xId = xa ? xa._id : '', + yId = ya ? ya._id : '', clipAxes = xId + yId; if(clipAxes) { diff --git a/src/components/rangeselector/get_update_object.js b/src/components/rangeselector/get_update_object.js index 740fce6c86b..fe6eacaa454 100644 --- a/src/components/rangeselector/get_update_object.js +++ b/src/components/rangeselector/get_update_object.js @@ -11,6 +11,8 @@ var d3 = require('d3'); +var Lib = require('../../lib'); + module.exports = function getUpdateObject(axisLayout, buttonLayout) { var axName = axisLayout._name; @@ -31,7 +33,7 @@ module.exports = function getUpdateObject(axisLayout, buttonLayout) { function getXRange(axisLayout, buttonLayout) { var currentRange = axisLayout.range; - var base = new Date(currentRange[1]); + var base = new Date(Lib.dateTime2ms(currentRange[1])); var step = buttonLayout.step, count = buttonLayout.count; @@ -40,13 +42,13 @@ function getXRange(axisLayout, buttonLayout) { switch(buttonLayout.stepmode) { case 'backward': - range0 = d3.time[step].offset(base, -count).getTime(); + range0 = Lib.ms2DateTime(+d3.time[step].offset(base, -count)); break; case 'todate': - var base2 = d3.time[step].offset(base, -(count - 1)); + var base2 = d3.time[step].offset(base, -count); - range0 = d3.time[step].floor(base2).getTime(); + range0 = Lib.ms2DateTime(+d3.time[step].ceil(base2)); break; } diff --git a/src/components/rangeslider/attributes.js b/src/components/rangeslider/attributes.js index e6d58be6e06..bb25d074c40 100644 --- a/src/components/rangeslider/attributes.js +++ b/src/components/rangeslider/attributes.js @@ -34,16 +34,20 @@ module.exports = { valType: 'info_array', role: 'info', items: [ - {valType: 'number'}, - {valType: 'number'} + {valType: 'any'}, + {valType: 'any'} ], description: [ 'Sets the range of the range slider.', 'If not set, defaults to the full xaxis range.', 'If the axis `type` is *log*, then you must take the', 'log of your desired range.', - 'If the axis `type` is *date*, then you must convert', - 'the date to unix time in milliseconds.' + 'If the axis `type` is *date*, it should be date strings,', + 'like date data, though Date objects and unix milliseconds', + 'will be accepted and converted to strings.', + 'If the axis `type` is *category*, it should be numbers,', + 'using the scale where each category is assigned a serial', + 'number from zero in the order it appears.' ].join(' ') }, thickness: { diff --git a/src/components/rangeslider/defaults.js b/src/components/rangeslider/defaults.js index 6e745a5624c..01c07091e01 100644 --- a/src/components/rangeslider/defaults.js +++ b/src/components/rangeslider/defaults.js @@ -21,7 +21,8 @@ module.exports = function handleDefaults(layoutIn, layoutOut, axName, counterAxe } var containerIn = layoutIn[axName].rangeslider, - containerOut = layoutOut[axName].rangeslider = {}; + axOut = layoutOut[axName], + containerOut = axOut.rangeslider = {}; function coerce(attr, dflt) { return Lib.coerce(containerIn, containerOut, attributes, attr, dflt); @@ -35,14 +36,16 @@ module.exports = function handleDefaults(layoutIn, layoutOut, axName, counterAxe coerce('range'); // Expand slider range to the axis range - if(containerOut.range && !layoutOut[axName].autorange) { + if(containerOut.range && !axOut.autorange) { var outRange = containerOut.range, - axRange = layoutOut[axName].range; + axRange = axOut.range, + l2r = axOut.l2r, + r2l = axOut.r2l; - outRange[0] = Math.min(outRange[0], axRange[0]); - outRange[1] = Math.max(outRange[1], axRange[1]); + outRange[0] = l2r(Math.min(r2l(outRange[0]), r2l(axRange[0]))); + outRange[1] = l2r(Math.max(r2l(outRange[1]), r2l(axRange[1]))); } else { - layoutOut[axName]._needsExpand = true; + axOut._needsExpand = true; } if(containerOut.visible) { diff --git a/src/components/rangeslider/draw.js b/src/components/rangeslider/draw.js index b3ea4b1ecc0..2a92d8d3091 100644 --- a/src/components/rangeslider/draw.js +++ b/src/components/rangeslider/draw.js @@ -104,20 +104,10 @@ module.exports = function(gd) { rangeSlider.attr('transform', 'translate(' + x + ',' + y + ')'); - // update inner nodes - - rangeSlider - .call(drawBg, gd, axisOpts, opts) - .call(addClipPath, gd, axisOpts, opts) - .call(drawRangePlot, gd, axisOpts, opts) - .call(drawMasks, gd, axisOpts, opts) - .call(drawSlideBox, gd, axisOpts, opts) - .call(drawGrabbers, gd, axisOpts, opts); - // update data <--> pixel coordinate conversion methods - var range0 = opts.range[0], - range1 = opts.range[1], + var range0 = axisOpts.r2l(opts.range[0]), + range1 = axisOpts.r2l(opts.range[1]), dist = range1 - range0; opts.p2d = function(v) { @@ -128,6 +118,18 @@ module.exports = function(gd) { return (v - range0) / dist * opts._width; }; + opts._rl = [range0, range1]; + + // update inner nodes + + rangeSlider + .call(drawBg, gd, axisOpts, opts) + .call(addClipPath, gd, axisOpts, opts) + .call(drawRangePlot, gd, axisOpts, opts) + .call(drawMasks, gd, axisOpts, opts) + .call(drawSlideBox, gd, axisOpts, opts) + .call(drawGrabbers, gd, axisOpts, opts); + // setup drag element setupDragElement(rangeSlider, gd, axisOpts, opts); @@ -165,8 +167,8 @@ function setupDragElement(rangeSlider, gd, axisOpts, opts) { target = event.target, startX = event.clientX, offsetX = startX - rangeSlider.node().getBoundingClientRect().left, - minVal = opts.d2p(axisOpts.range[0]), - maxVal = opts.d2p(axisOpts.range[1]); + minVal = opts.d2p(axisOpts._rl[0]), + maxVal = opts.d2p(axisOpts._rl[1]); var dragCover = dragElement.coverSlip(); @@ -227,7 +229,7 @@ function setupDragElement(rangeSlider, gd, axisOpts, opts) { function setDataRange(rangeSlider, gd, axisOpts, opts) { function clamp(v) { - return Lib.constrain(v, opts.range[0], opts.range[1]); + return axisOpts.l2r(Lib.constrain(v, opts._rl[0], opts._rl[1])); } var dataMin = clamp(opts.p2d(opts._pixelMin)), @@ -244,8 +246,8 @@ function setPixelRange(rangeSlider, gd, axisOpts, opts) { return Lib.constrain(v, 0, opts._width); } - var pixelMin = clamp(opts.d2p(axisOpts.range[0])), - pixelMax = clamp(opts.d2p(axisOpts.range[1])); + var pixelMin = clamp(opts.d2p(axisOpts._rl[0])), + pixelMax = clamp(opts.d2p(axisOpts._rl[1])); rangeSlider.select('rect.' + constants.slideBoxClassName) .attr('x', pixelMin) @@ -338,6 +340,7 @@ function drawRangePlot(rangeSlider, gd, axisOpts, opts) { data: [], layout: { xaxis: { + type: axisOpts.type, domain: [0, 1], range: opts.range.slice() }, diff --git a/src/components/shapes/draw.js b/src/components/shapes/draw.js index 487a44c4472..1d2f195f6b1 100644 --- a/src/components/shapes/draw.js +++ b/src/components/shapes/draw.js @@ -202,18 +202,17 @@ function updateShape(gd, index, opt, value) { var axLetter = posAttr.charAt(0), axOld = Axes.getFromId(gd, - Axes.coerceRef(oldRef, {}, gd, axLetter)), + Axes.coerceRef(oldRef, {}, gd, axLetter, '', 'paper')), axNew = Axes.getFromId(gd, - Axes.coerceRef(optionsIn, {}, gd, axLetter)), + Axes.coerceRef(optionsIn, {}, gd, axLetter, '', 'paper')), position = optionsIn[posAttr], - linearizedPosition; + rangePosition; if(optionsEdit[axLetter + 'ref'] !== undefined) { // first convert to fraction of the axis if(axOld) { - linearizedPosition = helpers.dataToLinear(axOld)(position); - position = (linearizedPosition - axOld.range[0]) / - (axOld.range[1] - axOld.range[0]); + rangePosition = helpers.shapePositionToRange(axOld)(position); + position = axOld.r2fraction(rangePosition); } else { position = (position - axNew.domain[0]) / (axNew.domain[1] - axNew.domain[0]); @@ -221,9 +220,8 @@ function updateShape(gd, index, opt, value) { if(axNew) { // then convert to new data coordinates at the same fraction - linearizedPosition = axNew.range[0] + position * - (axNew.range[1] - axNew.range[0]); - position = helpers.linearToData(axNew)(linearizedPosition); + rangePosition = axNew.fraction2r(position); + position = helpers.rangeToShapePosition(axNew)(rangePosition); } else { // or scale to the whole plot position = axOld.domain[0] + @@ -468,22 +466,22 @@ function getPathString(gd, options) { xa = Axes.getFromId(gd, options.xref), ya = Axes.getFromId(gd, options.yref), gs = gd._fullLayout._size, - x2l, + x2r, x2p, - y2l, + y2r, y2p; if(xa) { - x2l = helpers.dataToLinear(xa); - x2p = function(v) { return xa._offset + xa.l2p(x2l(v, true)); }; + x2r = helpers.shapePositionToRange(xa); + x2p = function(v) { return xa._offset + xa.r2p(x2r(v, true)); }; } else { x2p = function(v) { return gs.l + gs.w * v; }; } if(ya) { - y2l = helpers.dataToLinear(ya); - y2p = function(v) { return ya._offset + ya.l2p(y2l(v, true)); }; + y2r = helpers.shapePositionToRange(ya); + y2p = function(v) { return ya._offset + ya.r2p(y2r(v, true)); }; } else { y2p = function(v) { return gs.t + gs.h * (1 - v); }; diff --git a/src/components/shapes/helpers.js b/src/components/shapes/helpers.js index d4a5fc5fba9..76943237492 100644 --- a/src/components/shapes/helpers.js +++ b/src/components/shapes/helpers.js @@ -14,16 +14,16 @@ // so these have to be specified in terms of the category serial numbers, // but can take fractional values. Other axis types we specify position based on // the actual data values. -// TODO: this should really be part of axes, but for now it's only used here. -// eventually annotations and axis ranges will use this too. -// what should we do, invent a new letter for "data except if it's category"? +// TODO: in V2.0 (when log axis ranges are in data units) range and shape position +// will be identical, so rangeToShapePosition and shapePositionToRange can be +// removed entirely. -exports.dataToLinear = function(ax) { - return ax.type === 'category' ? ax.c2l : ax.d2l; +exports.rangeToShapePosition = function(ax) { + return (ax.type === 'log') ? ax.r2d : function(v) { return v; }; }; -exports.linearToData = function(ax) { - return ax.type === 'category' ? ax.l2c : ax.l2d; +exports.shapePositionToRange = function(ax) { + return (ax.type === 'log') ? ax.d2r : function(v) { return v; }; }; exports.decodeDate = function(convertToPx) { @@ -42,10 +42,10 @@ exports.getDataToPixel = function(gd, axis, isVertical) { dataToPixel; if(axis) { - var d2l = exports.dataToLinear(axis); + var d2r = exports.shapePositionToRange(axis); dataToPixel = function(v) { - return axis._offset + axis.l2p(d2l(v, true)); + return axis._offset + axis.r2p(d2r(v, true)); }; if(axis.type === 'date') dataToPixel = exports.decodeDate(dataToPixel); @@ -65,8 +65,8 @@ exports.getPixelToData = function(gd, axis, isVertical) { pixelToData; if(axis) { - var l2d = exports.linearToData(axis); - pixelToData = function(p) { return l2d(axis.p2l(p - axis._offset)); }; + var r2d = exports.rangeToShapePosition(axis); + pixelToData = function(p) { return r2d(axis.p2r(p - axis._offset)); }; } else if(isVertical) { pixelToData = function(p) { return 1 - (p - gs.t) / gs.h; }; diff --git a/src/components/shapes/shape_defaults.js b/src/components/shapes/shape_defaults.js index 04f70fb30b6..3386b9b156b 100644 --- a/src/components/shapes/shape_defaults.js +++ b/src/components/shapes/shape_defaults.js @@ -40,32 +40,54 @@ module.exports = function handleShapeDefaults(shapeIn, fullLayout) { var axLetters = ['x', 'y']; for(var i = 0; i < 2; i++) { var axLetter = axLetters[i], - tdMock = {_fullLayout: fullLayout}; + gdMock = {_fullLayout: fullLayout}; // xref, yref - var axRef = Axes.coerceRef(shapeIn, shapeOut, tdMock, axLetter); + var axRef = Axes.coerceRef(shapeIn, shapeOut, gdMock, axLetter, '', 'paper'); if(shapeType !== 'path') { var dflt0 = 0.25, - dflt1 = 0.75; + dflt1 = 0.75, + ax, + pos2r, + r2pos; if(axRef !== 'paper') { - var ax = Axes.getFromId(tdMock, axRef), - convertFn = helpers.linearToData(ax); - - dflt0 = convertFn(ax.range[0] + dflt0 * (ax.range[1] - ax.range[0])); - dflt1 = convertFn(ax.range[0] + dflt1 * (ax.range[1] - ax.range[0])); + ax = Axes.getFromId(gdMock, axRef); + r2pos = helpers.rangeToShapePosition(ax); + pos2r = helpers.shapePositionToRange(ax); + } + else { + pos2r = r2pos = Lib.identity; } + // hack until V2.0 when log has regular range behavior - make it look like other + // ranges to send to coerce, then put it back after + // this is all to give reasonable default position behavior on log axes, which is + // a pretty unimportant edge case so we could just ignore this. + var attr0 = axLetter + '0', + attr1 = axLetter + '1', + in0 = shapeIn[attr0], + in1 = shapeIn[attr1]; + shapeIn[attr0] = pos2r(shapeIn[attr0], true); + shapeIn[attr1] = pos2r(shapeIn[attr1], true); + // x0, x1 (and y0, y1) - coerce(axLetter + '0', dflt0); - coerce(axLetter + '1', dflt1); + Axes.coercePosition(shapeOut, gdMock, coerce, axRef, attr0, dflt0); + Axes.coercePosition(shapeOut, gdMock, coerce, axRef, attr1, dflt1); + + // hack part 2 + shapeOut[attr0] = r2pos(shapeOut[attr0]); + shapeOut[attr1] = r2pos(shapeOut[attr1]); + shapeIn[attr0] = in0; + shapeIn[attr1] = in1; } } if(shapeType === 'path') { coerce('path'); - } else { + } + else { Lib.noneOrAll(shapeIn, shapeOut, ['x0', 'x1', 'y0', 'y1']); } diff --git a/src/constants/numerical.js b/src/constants/numerical.js new file mode 100644 index 00000000000..d10ffa42a51 --- /dev/null +++ b/src/constants/numerical.js @@ -0,0 +1,40 @@ +/** +* Copyright 2012-2016, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + +'use strict'; + + +module.exports = { + /** + * Standardize all missing data in calcdata to use undefined + * never null or NaN. + * That way we can use !==undefined, or !== BADNUM, + * to test for real data + */ + BADNUM: undefined, + + /* + * Limit certain operations to well below floating point max value + * to avoid glitches: Make sure that even when you multiply it by the + * number of pixels on a giant screen it still works + */ + FP_SAFE: Number.MAX_VALUE / 10000, + + /* + * conversion of date units to milliseconds + * year and month constants are marked "AVG" + * to remind us that not all years and months + * have the same length + */ + ONEAVGYEAR: 31557600000, // 365.25 days + ONEAVGMONTH: 2629800000, // 1/12 of ONEAVGYEAR + ONEDAY: 86400000, + ONEHOUR: 3600000, + ONEMIN: 60000, + ONESEC: 1000 +}; diff --git a/src/lib/clean_number.js b/src/lib/clean_number.js new file mode 100644 index 00000000000..dbedfe2e2a6 --- /dev/null +++ b/src/lib/clean_number.js @@ -0,0 +1,32 @@ +/** +* Copyright 2012-2016, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + + +'use strict'; + +var isNumeric = require('fast-isnumeric'); + +var BADNUM = require('../constants/numerical').BADNUM; + +// precompile these regex's for speed +var FRONTJUNK = /^['"%,$#\s']+/; +var ENDJUNK = /['"%,$#\s']+$/; + +/** + * cleanNumber: remove common leading and trailing cruft + * Always returns either a number or BADNUM. + */ +module.exports = function cleanNumber(v) { + if(typeof v === 'string') { + v = v.replace(FRONTJUNK, '').replace(ENDJUNK, ''); + } + + if(isNumeric(v)) return Number(v); + + return BADNUM; +}; diff --git a/src/lib/dates.js b/src/lib/dates.js index d3779e90718..72dfecd5f72 100644 --- a/src/lib/dates.js +++ b/src/lib/dates.js @@ -12,6 +12,24 @@ var d3 = require('d3'); var isNumeric = require('fast-isnumeric'); +var logError = require('./loggers').error; + +var constants = require('../constants/numerical'); +var BADNUM = constants.BADNUM; +var ONEDAY = constants.ONEDAY; +var ONEHOUR = constants.ONEHOUR; +var ONEMIN = constants.ONEMIN; +var ONESEC = constants.ONESEC; + +// is an object a javascript date? +exports.isJSDate = function(v) { + return typeof v === 'object' && v !== null && typeof v.getTime === 'function'; +}; + +// The absolute limits of our date-time system +// This is a little weird: we use MIN_MS and MAX_MS in dateTime2ms +// but we use dateTime2ms to calculate them (after defining it!) +var MIN_MS, MAX_MS; /** * dateTime2ms - turn a date object or string s of the form @@ -19,7 +37,13 @@ var isNumeric = require('fast-isnumeric'); * per javascript standard) * may truncate after any full field, and sss can be any length * even >3 digits, though javascript dates truncate to milliseconds - * returns false if it doesn't find a date + * returns BADNUM if it doesn't find a date + * + * Expanded to support negative years to -9999 but you must always + * give 4 digits, except for 2-digit positive years which we assume are + * near the present time. + * Note that we follow ISO 8601:2004: there *is* a year 0, which + * is 1BC/BCE, and -1===2BC etc. * * 2-digit to 4-digit year conversion, where to cut off? * from http://support.microsoft.com/kb/244664: @@ -27,85 +51,123 @@ var isNumeric = require('fast-isnumeric'); * but in my mac chrome from eg. d=new Date(Date.parse('8/19/50')): * 1950-2049 * by Java, from http://stackoverflow.com/questions/2024273/: - * now-80 - now+20 + * now-80 - now+19 * or FileMaker Pro, from * http://www.filemaker.com/12help/html/add_view_data.4.21.html: - * now-70 - now+30 + * now-70 - now+29 * but python strptime etc, via * http://docs.python.org/py3k/library/time.html: * 1969-2068 (super forward-looking, but static, not sliding!) * - * lets go with now-70 to now+30, and if anyone runs into this problem + * lets go with now-70 to now+29, and if anyone runs into this problem * they can learn the hard way not to use 2-digit years, as no choice we * make now will cover all possibilities. mostly this will all be taken * care of in initial parsing, should only be an issue for hand-entered data - * currently (2012) this range is: - * 1942-2041 + * currently (2016) this range is: + * 1946-2045 */ exports.dateTime2ms = function(s) { // first check if s is a date object - try { - if(s.getTime) return +s; - } - catch(e) { - return false; + if(exports.isJSDate(s)) { + s = Number(s); + if(s >= MIN_MS && s <= MAX_MS) return s; + return BADNUM; } + // otherwise only accept strings and numbers + if(typeof s !== 'string' && typeof s !== 'number') return BADNUM; var y, m, d, h; // split date and time parts - var datetime = String(s).split(' '); - if(datetime.length > 2) return false; + // TODO: we strip leading/trailing whitespace but not other + // characters like we do for numbers - do we want to? + var datetime = String(s).trim().split(' '); + if(datetime.length > 2) return BADNUM; var p = datetime[0].split('-'); // date part - if(p.length > 3 || (p.length !== 3 && datetime[1])) return false; + + var CE = true; // common era, ie positive year + if(p[0] === '') { + // first part is blank: year starts with a minus sign + CE = false; + p.splice(0, 1); + } + + var plen = p.length; + if(plen > 3 || (plen !== 3 && datetime[1]) || !plen) return BADNUM; // year if(p[0].length === 4) y = Number(p[0]); else if(p[0].length === 2) { + if(!CE) return BADNUM; var yNow = new Date().getFullYear(); y = ((Number(p[0]) - yNow + 70) % 100 + 200) % 100 + yNow - 70; } - else return false; - if(!isNumeric(y)) return false; - if(p.length === 1) return new Date(y, 0, 1).getTime(); // year only - - // month - m = Number(p[1]) - 1; // new Date() uses zero-based months - if(p[1].length > 2 || !(m >= 0 && m <= 11)) return false; - if(p.length === 2) return new Date(y, m, 1).getTime(); // year-month - - // day - d = Number(p[2]); - if(p[2].length > 2 || !(d >= 1 && d <= 31)) return false; - - // now save the date part - d = new Date(y, m, d).getTime(); - if(!datetime[1]) return d; // year-month-day - p = datetime[1].split(':'); - if(p.length > 3) return false; - - // hour - h = Number(p[0]); - if(p[0].length > 2 || !(h >= 0 && h <= 23)) return false; - d += 3600000 * h; - if(p.length === 1) return d; - - // minute - m = Number(p[1]); - if(p[1].length > 2 || !(m >= 0 && m <= 59)) return false; - d += 60000 * m; - if(p.length === 2) return d; - - // second - s = Number(p[2]); - if(!(s >= 0 && s < 60)) return false; - return d + s * 1000; + else return BADNUM; + if(!isNumeric(y)) return BADNUM; + + // javascript takes new Date(0..99,m,d) to mean 1900-1999, so + // to support years 0-99 we need to use setFullYear explicitly + var baseDate = new Date(0, 0, 1); + baseDate.setFullYear(CE ? y : -y); + if(p.length > 1) { + + // month - may be 1 or 2 digits + m = Number(p[1]) - 1; // new Date() uses zero-based months + if(p[1].length > 2 || !(m >= 0 && m <= 11)) return BADNUM; + baseDate.setMonth(m); + + if(p.length > 2) { + + // day - may be 1 or 2 digits + d = Number(p[2]); + if(p[2].length > 2 || !(d >= 1 && d <= 31)) return BADNUM; + baseDate.setDate(d); + + // does that date exist in this month? + if(baseDate.getDate() !== d) return BADNUM; + + if(datetime[1]) { + + p = datetime[1].split(':'); + if(p.length > 3) return BADNUM; + + // hour - may be 1 or 2 digits + h = Number(p[0]); + if(p[0].length > 2 || !p[0].length || !(h >= 0 && h <= 23)) return BADNUM; + baseDate.setHours(h); + + // does that hour exist in this day? (Daylight time!) + // (TODO: remove this check when we move to UTC) + if(baseDate.getHours() !== h) return BADNUM; + + if(p.length > 1) { + d = baseDate.getTime(); + + // minute - must be 2 digits + m = Number(p[1]); + if(p[1].length !== 2 || !(m >= 0 && m <= 59)) return BADNUM; + d += ONEMIN * m; + if(p.length === 2) return d; + + // second (and milliseconds) - must have 2-digit seconds + if(p[2].split('.')[0].length !== 2) return BADNUM; + s = Number(p[2]); + if(!(s >= 0 && s < 60)) return BADNUM; + return d + s * ONESEC; + } + } + } + } + return baseDate.getTime(); }; +MIN_MS = exports.MIN_MS = exports.dateTime2ms('-9999'); +MAX_MS = exports.MAX_MS = exports.dateTime2ms('9999-12-31 23:59:59.9999'); + // is string s a date? (see above) exports.isDateTime = function(s) { - return (exports.dateTime2ms(s) !== false); + return (exports.dateTime2ms(s) !== BADNUM); }; // pad a number with zeroes, to given # of digits before the decimal point @@ -114,219 +176,63 @@ function lpad(val, digits) { } /** - * Turn ms into string of the form YYYY-mm-dd HH:MM:SS.sss - * Crop any trailing zeros in time, but always leave full date - * (we could choose to crop '-01' from date too)... + * Turn ms into string of the form YYYY-mm-dd HH:MM:SS.ssss + * Crop any trailing zeros in time, except never stop right after hours + * (we could choose to crop '-01' from date too but for now we always + * show the whole date) * Optional range r is the data range that applies, also in ms. * If rng is big, the later parts of time will be omitted */ +var NINETYDAYS = 90 * ONEDAY; +var THREEHOURS = 3 * ONEHOUR; +var FIVEMIN = 5 * ONEMIN; exports.ms2DateTime = function(ms, r) { + if(typeof ms !== 'number' || !(ms >= MIN_MS && ms <= MAX_MS)) return BADNUM; + if(!r) r = 0; - var d = new Date(ms), - s = d3.time.format('%Y-%m-%d')(d); - - if(r < 7776000000) { - // <90 days: add hours - s += ' ' + lpad(d.getHours(), 2); - if(r < 432000000) { - // <5 days: add minutes - s += ':' + lpad(d.getMinutes(), 2); - if(r < 10800000) { - // <3 hours: add seconds - s += ':' + lpad(d.getSeconds(), 2); - if(r < 300000) { - // <5 minutes: add ms - s += '.' + lpad(d.getMilliseconds(), 3); + var d = new Date(Math.floor(ms)), + dateStr = d3.time.format('%Y-%m-%d')(d), + // <90 days: add hours and minutes - never *only* add hours + h = (r < NINETYDAYS) ? d.getHours() : 0, + m = (r < NINETYDAYS) ? d.getMinutes() : 0, + // <3 hours: add seconds + s = (r < THREEHOURS) ? d.getSeconds() : 0, + // <5 minutes: add ms (plus one extra digit, this is msec*10) + msec10 = (r < FIVEMIN) ? Math.round((d.getMilliseconds() + (((ms % 1) + 1) % 1)) * 10) : 0; + + // include each part that has nonzero data in or after it + if(h || m || s || msec10) { + dateStr += ' ' + lpad(h, 2) + ':' + lpad(m, 2); + if(s || msec10) { + dateStr += ':' + lpad(s, 2); + if(msec10) { + var digits = 4; + while(msec10 % 10 === 0) { + digits -= 1; + msec10 /= 10; } + dateStr += '.' + lpad(msec10, digits); } } - // strip trailing zeros - return s.replace(/([:\s]00)*\.?[0]*$/, ''); } - return s; + return dateStr; }; -/** - * parseDate: forgiving attempt to turn any date string - * into a javascript date object - * - * first collate all the date formats we want to support, precompiled - * to d3 format objects see below for the string cleaning that happens - * before this separate out 2-digit (y) and 4-digit-year (Y) formats, - * formats with month names (b), and formats with am/pm (I) or no time (D) - * (also includes hour only, as the test is really for a colon) so we can - * cut down the number of tests we need to run for any given string - * (right now all are between 15 and 32 tests) - */ - -// TODO: this is way out of date vs. the server-side version -var timeFormats = { - // 24 hour - H: ['%H:%M:%S~%L', '%H:%M:%S', '%H:%M'], - // with am/pm - I: ['%I:%M:%S~%L%p', '%I:%M:%S%p', '%I:%M%p'], - // no colon, ie only date or date with hour (could also support eg 12h34m?) - D: ['%H', '%I%p', '%Hh'] -}; - -var dateFormats = { - Y: [ - '%Y~%m~%d', - '%Y%m%d', - '%y%m%d', // YYMMDD, has 6 digits together so will match Y, not y - '%m~%d~%Y', // MM/DD/YYYY has first precedence - '%d~%m~%Y' // then DD/MM/YYYY - ], - Yb: [ - '%b~%d~%Y', // eg nov 21 2013 - '%d~%b~%Y', // eg 21 nov 2013 - '%Y~%d~%b', // eg 2013 21 nov (or 2013 q3, after replacement) - '%Y~%b~%d' // eg 2013 nov 21 - ], - /** - * the two-digit year cases have so many potential ambiguities - * it's not even funny, but we'll try them anyway. - */ - y: [ - '%m~%d~%y', - '%d~%m~%y', - '%y~%m~%d' - ], - yb: [ - '%b~%d~%y', - '%d~%b~%y', - '%y~%d~%b', - '%y~%b~%d' - ] -}; - -// use utc formatter since we're ignoring timezone info -var formatter = d3.time.format.utc; - -/** - * ISO8601 and YYYYMMDDHHMMSS are the only ones where date and time - * are not separated by a space, so they get inserted specially here. - * Also a couple formats with no day (so time makes no sense) - */ -var dateTimeFormats = { - Y: { - H: ['%Y~%m~%dT%H:%M:%S', '%Y~%m~%dT%H:%M:%S~%L'].map(formatter), - I: [], - D: ['%Y%m%d%H%M%S', '%Y~%m', '%m~%Y'].map(formatter) - }, - Yb: {H: [], I: [], D: ['%Y~%b', '%b~%Y'].map(formatter)}, - y: {H: [], I: [], D: []}, - yb: {H: [], I: [], D: []} -}; -// all others get inserted in all possible combinations from dateFormats and timeFormats -['Y', 'Yb', 'y', 'yb'].forEach(function(dateType) { - dateFormats[dateType].forEach(function(dateFormat) { - // just a date (don't do just a time) - dateTimeFormats[dateType].D.push(formatter(dateFormat)); - ['H', 'I', 'D'].forEach(function(timeType) { - timeFormats[timeType].forEach(function(timeFormat) { - var a = dateTimeFormats[dateType][timeType]; - - // 'date time', then 'time date' - a.push(formatter(dateFormat + '~' + timeFormat)); - a.push(formatter(timeFormat + '~' + dateFormat)); - }); - }); - }); -}); - -// precompiled regexps for performance -var matchword = /[a-z]*/g, - shortenword = function(m) { return m.substr(0, 3); }, - weekdaymatch = /(mon|tue|wed|thu|fri|sat|sun|the|of|st|nd|rd|th)/g, - separatormatch = /[\s,\/\-\.\(\)]+/g, - ampmmatch = /~?([ap])~?m(~|$)/, - replaceampm = function(m, ap) { return ap + 'm '; }, - match4Y = /\d\d\d\d/, - matchMonthName = /(^|~)[a-z]{3}/, - matchAMPM = /[ap]m/, - matchcolon = /:/, - matchquarter = /q([1-4])/, - quarters = ['31~mar', '30~jun', '30~sep', '31~dec'], - replacequarter = function(m, n) { return quarters[n - 1]; }, - matchTZ = / ?([+\-]\d\d:?\d\d|Z)$/; - -function getDateType(v) { - var dateType; - dateType = (match4Y.test(v) ? 'Y' : 'y'); - dateType = dateType + (matchMonthName.test(v) ? 'b' : ''); - return dateType; -} - -function getTimeType(v) { - var timeType; - timeType = matchcolon.test(v) ? (matchAMPM.test(v) ? 'I' : 'H') : 'D'; - return timeType; -} - -exports.parseDate = function(v) { - // is it already a date? just return it - if(v.getTime) return v; - /** - * otherwise, if it's not a string, return nothing - * the case of numbers that just have years will get - * dealt with elsewhere. - */ - if(typeof v !== 'string') return false; - - // first clean up the string a bit to reduce the number of formats we have to test - v = v.toLowerCase() - /** - * cut all words down to 3 characters - this will result in - * some spurious matches, ie whenever the first three characters - * of a word match a month or weekday but that seems more likely - * to fix typos than to make dates where they shouldn't be... - * and then we can omit the long form of months from our testing - */ - .replace(matchword, shortenword) - /** - * remove weekday names, as they get overridden anyway if they're - * inconsistent also removes a few more words - * (ie "tuesday the 26th of november") - * TODO: language support? - * for months too, but these seem to be built into d3 - */ - .replace(weekdaymatch, '') - /** - * collapse all separators one ~ at a time, except : which seems - * pretty consistent for the time part use ~ instead of space or - * something since d3 can eat a space as padding on 1-digit numbers - */ - .replace(separatormatch, '~') - // in case of a.m. or p.m. (also take off any space before am/pm) - .replace(ampmmatch, replaceampm) - // turn quarters Q1-4 into dates (quarter ends) - .replace(matchquarter, replacequarter) - .trim() - // also try to ignore timezone info, at least for now - .replace(matchTZ, ''); - - // now test against the various formats that might match - var out = null, - dateType = getDateType(v), - timeType = getTimeType(v), - formatList, - len; - - formatList = dateTimeFormats[dateType][timeType]; - len = formatList.length; - - for(var i = 0; i < len; i++) { - out = formatList[i].parse(v); - if(out) break; +// normalize date format to date string, in case it starts as +// a Date object or milliseconds +// optional dflt is the return value if cleaning fails +exports.cleanDate = function(v, dflt) { + if(exports.isJSDate(v) || typeof v === 'number') { + // NOTE: if someone puts in a year as a number rather than a string, + // this will mistakenly convert it thinking it's milliseconds from 1970 + // that is: '2012' -> Jan. 1, 2012, but 2012 -> 2012 epoch milliseconds + v = exports.ms2DateTime(+v); + if(!v && dflt !== undefined) return dflt; } - - // If not an instance of Date at this point, just return it. - if(!(out instanceof Date)) return false; - // parse() method interprets arguments with local time zone. - var tzoff = out.getTimezoneOffset(); - // In general (default) this is not what we want, so force into UTC: - out.setTime(out.getTime() + tzoff * 60 * 1000); - return out; + else if(!exports.isDateTime(v)) { + logError('unrecognized date', v); + return dflt; + } + return v; }; diff --git a/src/lib/index.js b/src/lib/index.js index 27705ff8d8b..d0ca7757003 100644 --- a/src/lib/index.js +++ b/src/lib/index.js @@ -35,7 +35,10 @@ var datesModule = require('./dates'); lib.dateTime2ms = datesModule.dateTime2ms; lib.isDateTime = datesModule.isDateTime; lib.ms2DateTime = datesModule.ms2DateTime; -lib.parseDate = datesModule.parseDate; +lib.cleanDate = datesModule.cleanDate; +lib.isJSDate = datesModule.isJSDate; +lib.MIN_MS = datesModule.MIN_MS; +lib.MAX_MS = datesModule.MAX_MS; var searchModule = require('./search'); lib.findBin = searchModule.findBin; @@ -79,6 +82,8 @@ lib.filterUnique = require('./filter_unique'); lib.filterVisible = require('./filter_visible'); +lib.cleanNumber = require('./clean_number'); + /** * swap x and y of the same attribute in container cont * specify attr with a ? in place of x/y @@ -254,7 +259,7 @@ lib.smooth = function(arrayIn, FWHM) { * as long as its returns are not promises (ie have no .then) * includes one argument arg to send to all functions... * this is mainly just to prevent us having to make wrapper functions - * when the only purpose of the wrapper is to reference gd / td + * when the only purpose of the wrapper is to reference gd * and a final step to be executed at the end * TODO: if there's an error and everything is sync, * this doesn't happen yet because we want to make sure diff --git a/src/lib/svg_text_utils.js b/src/lib/svg_text_utils.js index 28b152085a8..2d229357f25 100644 --- a/src/lib/svg_text_utils.js +++ b/src/lib/svg_text_utils.js @@ -111,8 +111,8 @@ exports.convertToTspans = function(_context, _callback) { } if(tex) { - var td = Lib.getPlotDiv(that.node()); - ((td && td._promises) || []).push(new Promise(function(resolve) { + var gd = Lib.getPlotDiv(that.node()); + ((gd && gd._promises) || []).push(new Promise(function(resolve) { that.style({visibility: 'hidden'}); var config = {fontSize: parseInt(that.style('font-size'), 10)}; diff --git a/src/plot_api/to_image.js b/src/plot_api/to_image.js index 2f910af0b00..f86bb929395 100644 --- a/src/plot_api/to_image.js +++ b/src/plot_api/to_image.js @@ -52,7 +52,7 @@ function toImage(gd, opts) { // first clone the GD so we can operate in a clean environment var clone = clonePlot(gd, {format: 'png', height: opts.height, width: opts.width}); - var clonedGd = clone.td; + var clonedGd = clone.gd; // put the cloned div somewhere off screen before attaching to DOM clonedGd.style.position = 'absolute'; diff --git a/src/plots/cartesian/axes.js b/src/plots/cartesian/axes.js index 488ad0293b2..b3dbcc63626 100644 --- a/src/plots/cartesian/axes.js +++ b/src/plots/cartesian/axes.js @@ -19,6 +19,15 @@ var Titles = require('../../components/titles'); var Color = require('../../components/color'); var Drawing = require('../../components/drawing'); +var constants = require('../../constants/numerical'); +var FP_SAFE = constants.FP_SAFE; +var ONEAVGYEAR = constants.ONEAVGYEAR; +var ONEAVGMONTH = constants.ONEAVGMONTH; +var ONEDAY = constants.ONEDAY; +var ONEHOUR = constants.ONEHOUR; +var ONEMIN = constants.ONEMIN; +var ONESEC = constants.ONESEC; + var axes = module.exports = {}; @@ -36,43 +45,88 @@ axes.getFromId = axisIds.getFromId; axes.getFromTrace = axisIds.getFromTrace; -// find the list of possible axes to reference with an xref or yref attribute -// and coerce it to that list -axes.coerceRef = function(containerIn, containerOut, gd, axLetter, dflt) { - var axlist = gd._fullLayout._has('gl2d') ? [] : axes.listIds(gd, axLetter), - refAttr = axLetter + 'ref', +/* + * find the list of possible axes to reference with an xref or yref attribute + * and coerce it to that list + * + * attr: the attribute we're generating a reference for. Should end in 'x' or 'y' + * but can be prefixed, like 'ax' for annotation's arrow x + * dflt: the default to coerce to, or blank to use the first axis (falling back on + * extraOption if there is no axis) + * extraOption: aside from existing axes with this letter, what non-axis value is allowed? + * Only required if it's different from `dflt` + */ +axes.coerceRef = function(containerIn, containerOut, gd, attr, dflt, extraOption) { + var axLetter = attr.charAt(attr.length - 1), + axlist = gd._fullLayout._has('gl2d') ? [] : axes.listIds(gd, axLetter), + refAttr = attr + 'ref', attrDef = {}; + if(!dflt) dflt = axlist[0] || extraOption; + if(!extraOption) extraOption = dflt; + // data-ref annotations are not supported in gl2d yet attrDef[refAttr] = { valType: 'enumerated', - values: axlist.concat(['paper']), - dflt: dflt || axlist[0] || 'paper' + values: axlist.concat(extraOption ? [extraOption] : []), + dflt: dflt }; // xref, yref return Lib.coerce(containerIn, containerOut, attrDef, refAttr); }; -// todo: duplicated per github PR 610. Should be consolidated with axes.coerceRef. -// find the list of possible axes to reference with an axref or ayref attribute -// and coerce it to that list -axes.coerceARef = function(containerIn, containerOut, gd, axLetter, dflt) { - var axlist = gd._fullLayout._has('gl2d') ? [] : axes.listIds(gd, axLetter), - refAttr = 'a' + axLetter + 'ref', - attrDef = {}; - - // data-ref annotations are not supported in gl2d yet +/* + * coerce position attributes (range-type) that can be either on axes or absolute + * (paper or pixel) referenced. The biggest complication here is that we don't know + * before looking at the axis whether the value must be a number or not (it may be + * a date string), so we can't use the regular valType='number' machinery + * + * axRef (string): the axis this position is referenced to, or: + * paper: fraction of the plot area + * pixel: pixels relative to some starting position + * attr (string): the attribute in containerOut we are coercing + * dflt (number): the default position, as a fraction or pixels. If the attribute + * is to be axis-referenced, this will be converted to an axis data value + * + * Also cleans the values, since the attribute definition itself has to say + * valType: 'any' to handle date axes. This allows us to accept: + * - for category axes: category names, and convert them here into serial numbers. + * Note that this will NOT work for axis range endpoints, because we don't know + * the category list yet (it's set by ax.makeCalcdata during calc) + * but it works for component (note, shape, images) positions. + * - for date axes: JS Dates or milliseconds, and convert to date strings + * - for other types: coerce them to numbers + */ +axes.coercePosition = function(containerOut, gd, coerce, axRef, attr, dflt) { + var pos, + newPos; + + if(axRef === 'paper' || axRef === 'pixel') { + pos = coerce(attr, dflt); + } + else { + var ax = axes.getFromId(gd, axRef); - attrDef[refAttr] = { - valType: 'enumerated', - values: axlist.concat(['pixel']), - dflt: dflt || 'pixel' || axlist[0] - }; + dflt = ax.fraction2r(dflt); + pos = coerce(attr, dflt); - // axref, ayref - return Lib.coerce(containerIn, containerOut, attrDef, refAttr); + if(ax.type === 'category') { + // if position is given as a category name, convert it to a number + if(typeof pos === 'string' && (ax._categories || []).length) { + newPos = ax._categories.indexOf(pos); + containerOut[attr] = (newPos !== -1) ? dflt : newPos; + return; + } + } + else if(ax.type === 'date') { + containerOut[attr] = Lib.cleanDate(pos); + return; + } + } + // finally make sure we have a number (unless date type already returned a string) + containerOut[attr] = isNumeric(pos) ? Number(pos) : dflt; }; // empty out types for all axes containing these traces @@ -98,14 +152,16 @@ axes.counterLetter = function(id) { // incorporate a new minimum difference and first tick into // forced +// note that _forceTick0 is linearized, so needs to be turned into +// a range value for setting tick0 axes.minDtick = function(ax, newDiff, newFirst, allow) { // doesn't make sense to do forced min dTick on log or category axes, // and the plot itself may decide to cancel (ie non-grouped bars) if(['log', 'category'].indexOf(ax.type) !== -1 || !allow) { ax._minDtick = 0; } - // null means there's nothing there yet - else if(ax._minDtick === null) { + // undefined means there's nothing there yet + else if(ax._minDtick === undefined) { ax._minDtick = newDiff; ax._forceTick0 = newFirst; } @@ -130,6 +186,19 @@ axes.minDtick = function(ax, newDiff, newFirst, allow) { } }; +// Find the autorange for this axis +// +// assumes ax._min and ax._max have already been set by calling axes.expand +// using calcdata from all traces. These are arrays of: +// {val: calcdata value, pad: extra pixels beyond this value} +// +// Returns an array of [min, max]. These are calcdata for log and category axes +// and data for linear and date axes. +// +// TODO: we want to change log to data as well, but it's hard to do this +// maintaining backward compatibility. category will always have to use calcdata +// though, because otherwise values between categories (or outside all categories) +// would be impossible. axes.getAutoRange = function(ax) { var newRange = []; @@ -148,7 +217,12 @@ axes.getAutoRange = function(ax) { var j, minpt, maxpt, minbest, maxbest, dp, dv, mbest = 0, - axReverse = (ax.range && ax.range[1] < ax.range[0]); + axReverse = false; + + if(ax.range) { + var rng = ax.range.map(ax.r2l); + axReverse = rng[1] < rng[0]; + } // one-time setting to easily reverse the axis // when plotting from code @@ -237,11 +311,9 @@ axes.getAutoRange = function(ax) { } // maintain reversal - if(axReverse) { - newRange.reverse(); - } + if(axReverse) newRange.reverse(); - return newRange; + return newRange.map(ax.l2r || Number); }; axes.doAutoRange = function(ax) { @@ -304,7 +376,6 @@ axes.saveRangeInitial = function(gd, overwrite) { // (unless one end is overridden by tozero) // tozero: (boolean) make sure to include zero if axis is linear, // and make it a tight bound if possible -var FP_SAFE = Number.MAX_VALUE / 2; axes.expand = function(ax, data, options) { if(!(ax.autorange || ax._needsExpand) || !data) return; if(!ax._min) ax._min = []; @@ -443,10 +514,22 @@ axes.autoBin = function(data, ax, nbins, is2d) { } // piggyback off autotick code to make "nice" bin sizes - var dummyax = { - type: ax.type === 'log' ? 'linear' : ax.type, - range: [datamin, datamax] - }; + var dummyax; + if(ax.type === 'log') { + dummyax = { + type: 'linear', + range: [datamin, datamax] + }; + } + else { + dummyax = { + type: ax.type, + // conversion below would be ax.c2r but that's only different from l2r + // for log, and this is the only place (so far?) we would want c2r. + range: [datamin, datamax].map(ax.l2r) + }; + } + axes.autoTicks(dummyax, size0); var binstart = axes.tickIncrement( axes.tickFirst(dummyax), dummyax.dtick, 'reverse'), @@ -528,6 +611,8 @@ axes.autoBin = function(data, ax, nbins, is2d) { axes.calcTicks = function calcTicks(ax) { if(ax.tickmode === 'array') return arrayTicks(ax); + var rng = ax.range.map(ax.r2l); + // calculate max number of (auto) ticks to display based on plot size if(ax.tickmode === 'auto' || !ax.dtick) { var nt = ax.nticks, @@ -542,18 +627,17 @@ axes.calcTicks = function calcTicks(ax) { nt = Lib.constrain(ax._length / minPx, 4, 9) + 1; } } - axes.autoTicks(ax, Math.abs(ax.range[1] - ax.range[0]) / nt); + axes.autoTicks(ax, Math.abs(rng[1] - rng[0]) / nt); // check for a forced minimum dtick if(ax._minDtick > 0 && ax.dtick < ax._minDtick * 2) { ax.dtick = ax._minDtick; - ax.tick0 = ax._forceTick0; + ax.tick0 = ax.l2r(ax._forceTick0); } } // check for missing tick0 if(!ax.tick0) { - ax.tick0 = (ax.type === 'date') ? - new Date(2000, 0, 1).getTime() : 0; + ax.tick0 = (ax.type === 'date') ? '2000-01-01' : 0; } // now figure out rounding of tick values @@ -563,12 +647,12 @@ axes.calcTicks = function calcTicks(ax) { ax._tmin = axes.tickFirst(ax); // check for reversed axis - var axrev = (ax.range[1] < ax.range[0]); + var axrev = (rng[1] < rng[0]); // return the full set of tick vals var vals = [], // add a tiny bit so we get ticks which may have rounded out - endtick = ax.range[1] * 1.0001 - ax.range[0] * 0.0001; + endtick = rng[1] * 1.0001 - rng[0] * 0.0001; if(ax.type === 'category') { endtick = (axrev) ? Math.max(-0.5, endtick) : Math.min(ax._categories.length - 0.5, endtick); @@ -586,9 +670,18 @@ axes.calcTicks = function calcTicks(ax) { // show the exponent only on the last one ax._tmax = vals[vals.length - 1]; + // for showing date suffixes: ax._prevSuffix holds what we showed most + // recently. Start with it cleared and mark that we're in calcTicks (ie + // calculating a whole string of these so we should care what the previous + // suffix was!) + ax._prevSuffix = ''; + ax._inCalcTicks = true; + var ticksOut = new Array(vals.length); for(var i = 0; i < vals.length; i++) ticksOut[i] = axes.tickText(ax, vals[i]); + ax._inCalcTicks = false; + return ticksOut; }; @@ -596,15 +689,15 @@ function arrayTicks(ax) { var vals = ax.tickvals, text = ax.ticktext, ticksOut = new Array(vals.length), - r0expanded = ax.range[0] * 1.0001 - ax.range[1] * 0.0001, - r1expanded = ax.range[1] * 1.0001 - ax.range[0] * 0.0001, + rng = ax.range.map(ax.r2l), + r0expanded = rng[0] * 1.0001 - rng[1] * 0.0001, + r1expanded = rng[1] * 1.0001 - rng[0] * 0.0001, tickMin = Math.min(r0expanded, r1expanded), tickMax = Math.max(r0expanded, r1expanded), vali, i, j = 0; - // without a text array, just format the given values as any other ticks // except with more precision to the numbers if(!Array.isArray(text)) text = []; @@ -644,7 +737,7 @@ function roundDTick(roughDTick, base, roundingSet) { // outputs (into ax): // tick0: starting point for ticks (not necessarily on the graph) // usually 0 for numeric (=10^0=1 for log) or jan 1, 2000 for dates -// dtick: the actual, nice round tick spacing, somewhat larger than roughDTick +// dtick: the actual, nice round tick spacing, usually a little larger than roughDTick // if the ticks are spaced linearly (linear scale, categories, // log with only full powers, date ticks < month), // this will just be a number @@ -657,36 +750,35 @@ axes.autoTicks = function(ax, roughDTick) { var base; if(ax.type === 'date') { - ax.tick0 = new Date(2000, 0, 1).getTime(); + ax.tick0 = '2000-01-01'; + // the criteria below are all based on the rough spacing we calculate + // being > half of the final unit - so precalculate twice the rough val + var roughX2 = 2 * roughDTick; - if(roughDTick > 15778800000) { - // years if roughDTick > 6mo - roughDTick /= 31557600000; + if(roughX2 > ONEAVGYEAR) { + roughDTick /= ONEAVGYEAR; base = Math.pow(10, Math.floor(Math.log(roughDTick) / Math.LN10)); ax.dtick = 'M' + (12 * roundDTick(roughDTick, base, roundBase10)); } - else if(roughDTick > 1209600000) { - // months if roughDTick > 2wk - roughDTick /= 2629800000; + else if(roughX2 > ONEAVGMONTH) { + roughDTick /= ONEAVGMONTH; ax.dtick = 'M' + roundDTick(roughDTick, 1, roundBase24); } - else if(roughDTick > 43200000) { - // days if roughDTick > 12h - ax.dtick = roundDTick(roughDTick, 86400000, roundDays); + else if(roughX2 > ONEDAY) { + ax.dtick = roundDTick(roughDTick, ONEDAY, roundDays); // get week ticks on sunday - ax.tick0 = new Date(2000, 0, 2).getTime(); + // this will also move the base tick off 2000-01-01 if dtick is + // 2 or 3 days... but that's a weird enough case that we'll ignore it. + ax.tick0 = '2000-01-02'; } - else if(roughDTick > 1800000) { - // hours if roughDTick > 30m - ax.dtick = roundDTick(roughDTick, 3600000, roundBase24); + else if(roughX2 > ONEHOUR) { + ax.dtick = roundDTick(roughDTick, ONEHOUR, roundBase24); } - else if(roughDTick > 30000) { - // minutes if roughDTick > 30sec - ax.dtick = roundDTick(roughDTick, 60000, roundBase60); + else if(roughX2 > ONEMIN) { + ax.dtick = roundDTick(roughDTick, ONEMIN, roundBase60); } - else if(roughDTick > 500) { - // seconds if roughDTick > 0.5sec - ax.dtick = roundDTick(roughDTick, 1000, roundBase60); + else if(roughX2 > ONESEC) { + ax.dtick = roundDTick(roughDTick, ONESEC, roundBase60); } else { // milliseconds @@ -696,16 +788,19 @@ axes.autoTicks = function(ax, roughDTick) { } else if(ax.type === 'log') { ax.tick0 = 0; + var rng = ax.range.map(ax.r2l); - // only show powers of 10 - if(roughDTick > 0.7) ax.dtick = Math.ceil(roughDTick); - else if(Math.abs(ax.range[1] - ax.range[0]) < 1) { + if(roughDTick > 0.7) { + // only show powers of 10 + ax.dtick = Math.ceil(roughDTick); + } + else if(Math.abs(rng[1] - rng[0]) < 1) { // span is less than one power of 10 - var nt = 1.5 * Math.abs((ax.range[1] - ax.range[0]) / roughDTick); + var nt = 1.5 * Math.abs((rng[1] - rng[0]) / roughDTick); // ticks on a linear scale, labeled fully - roughDTick = Math.abs(Math.pow(10, ax.range[1]) - - Math.pow(10, ax.range[0])) / nt; + roughDTick = Math.abs(Math.pow(10, rng[1]) - + Math.pow(10, rng[0])) / nt; base = Math.pow(10, Math.floor(Math.log(roughDTick) / Math.LN10)); ax.dtick = 'L' + roundDTick(roughDTick, base, roundBase10); } @@ -744,41 +839,59 @@ axes.autoTicks = function(ax, roughDTick) { // for date ticks, the last date part to show (y,m,d,H,M,S) // or an integer # digits past seconds function autoTickRound(ax) { - var dtick = ax.dtick, - maxend; + var dtick = ax.dtick; ax._tickexponent = 0; - if(!isNumeric(dtick) && typeof dtick !== 'string') dtick = 1; + if(!isNumeric(dtick) && typeof dtick !== 'string') { + dtick = 1; + } - if(ax.type === 'category') ax._tickround = null; - else if(isNumeric(dtick) || dtick.charAt(0) === 'L') { - if(ax.type === 'date') { - if(dtick >= 86400000) ax._tickround = 'd'; - else if(dtick >= 3600000) ax._tickround = 'H'; - else if(dtick >= 60000) ax._tickround = 'M'; - else if(dtick >= 1000) ax._tickround = 'S'; - else ax._tickround = 3 - Math.round(Math.log(dtick / 2) / Math.LN10); - } + if(ax.type === 'category') { + ax._tickround = null; + } + if(ax.type === 'date') { + // If tick0 is unusual, give tickround a bit more information + // not necessarily *all* the information in tick0 though, if it's really odd + // minimal string length for tick0: 'd' is 10, 'M' is 16, 'S' is 19 + // take off a leading minus (year < 0 so length is consistent) + var tick0ms = Lib.dateTime2ms(ax.tick0), + tick0str = Lib.ms2DateTime(tick0ms).replace(/^-/, ''), + tick0len = tick0str.length; + + if(String(dtick).charAt(0) === 'M') { + // any tick0 more specific than a year: alway show the full date + if(tick0len > 10 || tick0str.substr(5) !== '01-01') ax._tickround = 'd'; + // show the month unless ticks are full multiples of a year + else ax._tickround = (+(dtick.substr(1)) % 12 === 0) ? 'y' : 'm'; + } + else if((dtick >= ONEDAY && tick0len <= 10) || (dtick >= ONEDAY * 15)) ax._tickround = 'd'; + else if((dtick >= ONEMIN && tick0len <= 16) || (dtick >= ONEHOUR)) ax._tickround = 'M'; + else if((dtick >= ONESEC && tick0len <= 19) || (dtick >= ONEMIN)) ax._tickround = 'S'; else { - if(!isNumeric(dtick)) dtick = Number(dtick.substr(1)); - // 2 digits past largest digit of dtick - ax._tickround = 2 - Math.floor(Math.log(dtick) / Math.LN10 + 0.01); - - if(ax.type === 'log') { - maxend = Math.pow(10, Math.max(ax.range[0], ax.range[1])); - } - else maxend = Math.max(Math.abs(ax.range[0]), Math.abs(ax.range[1])); - - var rangeexp = Math.floor(Math.log(maxend) / Math.LN10 + 0.01); - if(Math.abs(rangeexp) > 3) { - if(ax.exponentformat === 'SI' || ax.exponentformat === 'B') { - ax._tickexponent = 3 * Math.round((rangeexp - 1) / 3); - } - else ax._tickexponent = rangeexp; + // of any two adjacent ticks, at least one will have the maximum fractional digits + // of all possible ticks - so take the max. length of tick0 and the next one + var tick1len = Lib.ms2DateTime(tick0ms + dtick).replace(/^-/, '').length; + ax._tickround = Math.max(tick0len, tick1len) - 20; + } + } + else if(isNumeric(dtick) || dtick.charAt(0) === 'L') { + // linear or log (except D1, D2) + var rng = ax.range.map(ax.r2d || Number); + if(!isNumeric(dtick)) dtick = Number(dtick.substr(1)); + // 2 digits past largest digit of dtick + ax._tickround = 2 - Math.floor(Math.log(dtick) / Math.LN10 + 0.01); + + var maxend = Math.max(Math.abs(rng[0]), Math.abs(rng[1])); + + var rangeexp = Math.floor(Math.log(maxend) / Math.LN10 + 0.01); + if(Math.abs(rangeexp) > 3) { + if(ax.exponentformat === 'SI' || ax.exponentformat === 'B') { + ax._tickexponent = 3 * Math.round((rangeexp - 1) / 3); } + else ax._tickexponent = rangeexp; } } - else if(dtick.charAt(0) === 'M') ax._tickround = (dtick.length === 2) ? 'm' : 'y'; + // D1 or D2 (log) else ax._tickround = null; } @@ -823,13 +936,16 @@ axes.tickIncrement = function(x, dtick, axrev) { // calculate the first tick on an axis axes.tickFirst = function(ax) { - var axrev = ax.range[1] < ax.range[0], + var r2l = ax.r2l || Number, + rng = ax.range.map(r2l), + axrev = rng[1] < rng[0], sRound = axrev ? Math.floor : Math.ceil, // add a tiny extra bit to make sure we get ticks // that may have been rounded out - r0 = ax.range[0] * 1.0001 - ax.range[1] * 0.0001, + r0 = rng[0] * 1.0001 - rng[1] * 0.0001, dtick = ax.dtick, - tick0 = ax.tick0; + tick0 = r2l(ax.tick0); + if(isNumeric(dtick)) { var tmin = sRound((r0 - tick0) / dtick) * dtick + tick0; @@ -879,7 +995,7 @@ axes.tickFirst = function(ax) { var yearFormat = d3.time.format('%Y'), monthFormat = d3.time.format('%b %Y'), dayFormat = d3.time.format('%b %-d'), - hourFormat = d3.time.format('%b %-d %Hh'), + yearMonthDayFormat = d3.time.format('%b %-d, %Y'), minuteFormat = d3.time.format('%H:%M'), secondFormat = d3.time.format(':%S'); @@ -915,7 +1031,8 @@ axes.tickText = function(ax, x, hover) { i; if(arrayMode && Array.isArray(ax.ticktext)) { - var minDiff = Math.abs(ax.range[1] - ax.range[0]) / 10000; + var rng = ax.range.map(ax.r2l), + minDiff = Math.abs(rng[1] - rng[0]) / 10000; for(i = 0; i < ax.ticktext.length; i++) { if(Math.abs(x - ax.d2l(ax.tickvals[i])) < minDiff) break; } @@ -970,10 +1087,12 @@ function tickTextObj(ax, x, text) { function formatDate(ax, out, hover, extraPrecision) { var x = out.x, tr = ax._tickround, + trOriginal = tr, d = new Date(x), // suffix completes the full date info, to be included - // with only the first tick - suffix = '', + // with only the first tick or if any info before what's + // shown has changed + suffix, tt; if(hover && ax.hoverformat) { tt = modDateFormat(ax.hoverformat, x); @@ -986,34 +1105,40 @@ function formatDate(ax, out, hover, extraPrecision) { else { if(extraPrecision) { if(isNumeric(tr)) tr += 2; - else tr = {y: 'm', m: 'd', d: 'H', H: 'M', M: 'S', S: 2}[tr]; + else tr = {y: 'm', m: 'd', d: 'M', M: 'S', S: 2}[tr]; } if(tr === 'y') tt = yearFormat(d); else if(tr === 'm') tt = monthFormat(d); else { - if(x === ax._tmin && !hover) { - suffix = '
' + yearFormat(d); - } + if(tr === 'd') { + if(!hover) suffix = '
' + yearFormat(d); - if(tr === 'd') tt = dayFormat(d); - else if(tr === 'H') tt = hourFormat(d); + tt = dayFormat(d); + } else { - if(x === ax._tmin && !hover) { - suffix = '
' + dayFormat(d) + ', ' + yearFormat(d); - } + if(!hover) suffix = '
' + yearMonthDayFormat(d); tt = minuteFormat(d); if(tr !== 'M') { tt += secondFormat(d); if(tr !== 'S') { - tt += numFormat(mod(x / 1000, 1), ax, 'none', hover) + tt += numFormat(d3.round(mod(x / 1000, 1), 4), ax, 'none', hover) .substr(1); } } + else if(trOriginal === 'd') { + // for hover on axes with day ticks, minuteFormat (which + // only includes %H:%M) isn't enough, you want the date too + tt = dayFormat(d) + ' ' + tt; + } } } } - out.text = tt + suffix; + if(suffix && (!ax._inCalcTicks || (suffix !== ax._prevSuffix))) { + tt += suffix; + ax._prevSuffix = suffix; + } + out.text = tt; } function formatLog(ax, out, hover, extraPrecision, hideexp) { @@ -1105,7 +1230,7 @@ function numFormat(v, ax, fmtoverride, hover) { (isNumeric(v) ? Math.abs(v) || 1 : 1), // if not showing any exponents, don't change the exponent // from what we calculate - range: ax.showexponent === 'none' ? ax.range : [0, v || 1] + range: ax.showexponent === 'none' ? ax.range.map(ax.r2d) : [0, v || 1] }; autoTickRound(ah); tickRound = (Number(ah._tickround) || 0) + 4; @@ -1353,8 +1478,9 @@ axes.makeClipPaths = function(gd) { // doTicks: draw ticks, grids, and tick labels // axid: 'x', 'y', 'x2' etc, // blank to do all, -// 'redraw' to force full redraw, and reset ax._r -// (stored range for use by zoom/pan) +// 'redraw' to force full redraw, and reset: +// ax._r (stored range for use by zoom/pan) +// ax._rl (stored linearized range for use by zoom/pan) // or can pass in an axis object directly axes.doTicks = function(gd, axid, skipTitle) { var fullLayout = gd._fullLayout, @@ -1392,7 +1518,10 @@ axes.doTicks = function(gd, axid, skipTitle) { return function() { if(!ax._id) return; var axDone = axes.doTicks(gd, ax._id); - if(axid === 'redraw') ax._r = ax.range.slice(); + if(axid === 'redraw') { + ax._r = ax.range.slice(); + ax._rl = ax._r.map(ax.r2l); + } return axDone; }; })); @@ -1410,9 +1539,6 @@ axes.doTicks = function(gd, axid, skipTitle) { } } - // in case a val turns into string somehow - ax.range = [+ax.range[0], +ax.range[1]]; - // set scaling to pixels ax.setScale(); @@ -1801,7 +1927,8 @@ axes.doTicks = function(gd, axid, skipTitle) { break; } } - var showZl = (ax.range[0] * ax.range[1] <= 0) && ax.zeroline && + var rng = ax.range.map(ax.r2l), + showZl = (rng[0] * rng[1] <= 0) && ax.zeroline && (ax.type === 'linear' || ax.type === '-') && gridvals.length && (hasBarsOrFill || clipEnds({x: 0}) || !ax.showline); var zl = zlcontainer.selectAll('path.' + zcls) diff --git a/src/plots/cartesian/axis_autotype.js b/src/plots/cartesian/axis_autotype.js index 8202a7778d3..00139887d28 100644 --- a/src/plots/cartesian/axis_autotype.js +++ b/src/plots/cartesian/axis_autotype.js @@ -12,7 +12,7 @@ var isNumeric = require('fast-isnumeric'); var Lib = require('../../lib'); -var cleanDatum = require('./clean_datum'); +var BADNUM = require('../../constants/numerical').BADNUM; module.exports = function autoType(array) { if(moreDates(array)) return 'date'; @@ -54,7 +54,7 @@ function moreDates(a) { return (dcnt > ncnt * 2); } -// are the (x,y)-values in td.data mostly text? +// are the (x,y)-values in gd.data mostly text? // require twice as many categories as numbers function category(a) { // test at most 1000 points @@ -64,8 +64,8 @@ function category(a) { ai; for(var i = 0; i < a.length; i += inc) { - ai = cleanDatum(a[Math.round(i)]); - if(isNumeric(ai)) curvenums++; + ai = a[Math.round(i)]; + if(Lib.cleanNumber(ai) !== BADNUM) curvenums++; else if(typeof ai === 'string' && ai !== '' && ai !== 'None') curvecats++; } diff --git a/src/plots/cartesian/axis_defaults.js b/src/plots/cartesian/axis_defaults.js index 7d27bffc437..0bfdbde1ff2 100644 --- a/src/plots/cartesian/axis_defaults.js +++ b/src/plots/cartesian/axis_defaults.js @@ -90,17 +90,15 @@ module.exports = function handleAxisDefaults(containerIn, containerOut, coerce, var validRange = ( (containerIn.range || []).length === 2 && - isNumeric(containerIn.range[0]) && - isNumeric(containerIn.range[1]) + isNumeric(containerOut.r2l(containerIn.range[0])) && + isNumeric(containerOut.r2l(containerIn.range[1])) ); var autoRange = coerce('autorange', !validRange); if(autoRange) coerce('rangemode'); - var range = coerce('range', [-1, letter === 'x' ? 6 : 4]); - if(range[0] === range[1]) { - containerOut.range = [range[0] - 1, range[0] + 1]; - } - Lib.noneOrAll(containerIn.range, containerOut.range, [0, 1]); + + coerce('range'); + containerOut.cleanRange(); coerce('fixedrange'); diff --git a/src/plots/cartesian/clean_datum.js b/src/plots/cartesian/clean_datum.js deleted file mode 100644 index 086d412fdf3..00000000000 --- a/src/plots/cartesian/clean_datum.js +++ /dev/null @@ -1,37 +0,0 @@ -/** -* Copyright 2012-2016, Plotly, Inc. -* All rights reserved. -* -* This source code is licensed under the MIT license found in the -* LICENSE file in the root directory of this source tree. -*/ - - -'use strict'; - -var isNumeric = require('fast-isnumeric'); - -var Lib = require('../../lib'); - - -/** - * cleanDatum: removes characters - * same replace criteria used in the grid.js:scrapeCol - * but also handling dates, numbers, and NaN, null, Infinity etc - */ -module.exports = function cleanDatum(c) { - try { - if(typeof c === 'object' && c !== null && c.getTime) { - return Lib.ms2DateTime(c); - } - if(typeof c !== 'string' && !isNumeric(c)) { - return ''; - } - c = c.toString().replace(/['"%,$# ]/g, ''); - } - catch(e) { - Lib.error(e, c); - } - - return c; -}; diff --git a/src/plots/cartesian/constants.js b/src/plots/cartesian/constants.js index 81467cee968..765241182a6 100644 --- a/src/plots/cartesian/constants.js +++ b/src/plots/cartesian/constants.js @@ -21,14 +21,6 @@ module.exports = { y: /^yaxis([2-9]|[1-9][0-9]+)?$/ }, - /** - * standardize all missing data in calcdata to use undefined - * never null or NaN. - * that way we can use !==undefined, or !== BADNUM, - * to test for real data - */ - BADNUM: undefined, - // axis match regular expression xAxisMatch: /^xaxis[0-9]*$/, yAxisMatch: /^yaxis[0-9]*$/, @@ -72,5 +64,10 @@ module.exports = { BENDPX: 1.5, // delay before a redraw (relayout) after smooth panning and zooming - REDRAWDELAY: 50 + REDRAWDELAY: 50, + + // last resort axis ranges for x, y, and date axes if we have no data + DFLTRANGEX: [-1, 6], + DFLTRANGEY: [-1, 4], + DFLTRANGEDATE: ['2000-01-01', '2001-01-01'], }; diff --git a/src/plots/cartesian/dragbox.js b/src/plots/cartesian/dragbox.js index d2f5271ba3d..0df91d1ec77 100644 --- a/src/plots/cartesian/dragbox.js +++ b/src/plots/cartesian/dragbox.js @@ -98,11 +98,6 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { return dragger; } - function forceNumbers(axRange) { - axRange[0] = Number(axRange[0]); - axRange[1] = Number(axRange[1]); - } - var dragOptions = { element: dragger, gd: gd, @@ -213,7 +208,6 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { .attr('d', 'M0,0Z'); clearSelect(); - for(var i = 0; i < allaxes.length; i++) forceNumbers(allaxes[i].range); } function clearSelect() { @@ -304,16 +298,16 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { function zoomAxRanges(axList, r0Fraction, r1Fraction) { var i, axi, - axRange; + axRangeLinear; for(i = 0; i < axList.length; i++) { axi = axList[i]; if(axi.fixedrange) continue; - axRange = axi.range; + axRangeLinear = axi.range.map(axi.r2l); axi.range = [ - axRange[0] + (axRange[1] - axRange[0]) * r0Fraction, - axRange[0] + (axRange[1] - axRange[0]) * r1Fraction + axi.l2r(axRangeLinear[0] + (axRangeLinear[1] - axRangeLinear[0]) * r0Fraction), + axi.l2r(axRangeLinear[0] + (axRangeLinear[1] - axRangeLinear[0]) * r1Fraction) ]; } } @@ -367,7 +361,7 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { verticalAlign: vAlign }) .on('edit', function(text) { - var v = ax.type === 'category' ? ax.c2l(text) : ax.d2l(text); + var v = ax.d2r(text); if(v !== undefined) { Plotly.relayout(gd, attrStr, v); } @@ -427,10 +421,11 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { function zoomWheelOneAxis(ax, centerFraction, zoom) { if(ax.fixedrange) return; - forceNumbers(ax.range); - var axRange = ax.range, + + var axRange = ax.range.map(ax.r2l), v0 = axRange[0] + (axRange[1] - axRange[0]) * centerFraction; - ax.range = [v0 + (axRange[0] - v0) * zoom, v0 + (axRange[1] - v0) * zoom]; + function doZoom(v) { return ax.l2r(v0 + (v - v0) * zoom); } + ax.range = axRange.map(doZoom); } if(ew) { @@ -478,7 +473,10 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { for(var i = 0; i < axList.length; i++) { var axi = axList[i]; if(!axi.fixedrange) { - axi.range = [axi._r[0] - pix / axi._m, axi._r[1] - pix / axi._m]; + axi.range = [ + axi.l2r(axi._rl[0] - pix / axi._m), + axi.l2r(axi._rl[1] - pix / axi._m) + ]; } } } @@ -501,23 +499,29 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { 1 / (1 / Math.max(d, -0.3) + 3.222)); } - // dz: set a new value for one end (0 or 1) of an axis array ax, + // dz: set a new value for one end (0 or 1) of an axis array axArray, // and return a pixel shift for that end for the viewbox // based on pixel drag distance d // TODO: this makes (generally non-fatal) errors when you get // near floating point limits - function dz(ax, end, d) { + function dz(axArray, end, d) { var otherEnd = 1 - end, - movedi = 0; - for(var i = 0; i < ax.length; i++) { - var axi = ax[i]; + movedAx, + newLinearizedEnd; + for(var i = 0; i < axArray.length; i++) { + var axi = axArray[i]; if(axi.fixedrange) continue; - movedi = i; - axi.range[end] = axi._r[otherEnd] + - (axi._r[end] - axi._r[otherEnd]) / dZoom(d / axi._length); + movedAx = axi; + newLinearizedEnd = axi._rl[otherEnd] + + (axi._rl[end] - axi._rl[otherEnd]) / dZoom(d / axi._length); + var newEnd = axi.l2r(newLinearizedEnd); + + // if l2r comes back false or undefined, it means we've dragged off + // the end of valid ranges - so stop. + if(newEnd !== false && newEnd !== undefined) axi.range[end] = newEnd; } - return ax[movedi]._length * (ax[movedi]._r[end] - ax[movedi].range[end]) / - (ax[movedi]._r[end] - ax[movedi]._r[otherEnd]); + return movedAx._length * (movedAx._rl[end] - newLinearizedEnd) / + (movedAx._rl[end] - movedAx._rl[otherEnd]); } if(xActive === 'w') dx = dz(xa, 0, dx); @@ -719,8 +723,10 @@ function getEndText(ax, end) { diff = Math.abs(initialVal - ax.range[1 - end]), dig; + // TODO: this should basically be ax.r2d but we're doing extra + // rounding here... can we clean up at all? if(ax.type === 'date') { - return Lib.ms2DateTime(initialVal, diff); + return initialVal; } else if(ax.type === 'log') { dig = Math.ceil(Math.max(0, -Math.log(diff) / Math.LN10)) + 3; diff --git a/src/plots/cartesian/layout_attributes.js b/src/plots/cartesian/layout_attributes.js index b1465d5836b..89f0f9dcbdc 100644 --- a/src/plots/cartesian/layout_attributes.js +++ b/src/plots/cartesian/layout_attributes.js @@ -40,8 +40,8 @@ module.exports = { type: { valType: 'enumerated', // '-' means we haven't yet run autotype or couldn't find any data - // it gets turned into linear in td._fullLayout but not copied back - // to td.data like the others are. + // it gets turned into linear in gd._fullLayout but not copied back + // to gd.data like the others are. values: ['-', 'linear', 'log', 'date', 'category'], dflt: '-', role: 'info', @@ -82,16 +82,20 @@ module.exports = { valType: 'info_array', role: 'info', items: [ - {valType: 'number'}, - {valType: 'number'} + {valType: 'any'}, + {valType: 'any'} ], description: [ 'Sets the range of this axis.', - 'If the axis `type` is *log*, then you must take the log of your desired range', - '(e.g. to set the range from 1 to 100, set the range from 0 to 2).', - 'If the axis `type` is *date*, then you must convert the date to unix time in milliseconds', - '(the number of milliseconds since January 1st, 1970). For example, to set the date range from', - 'January 1st 1970 to November 4th, 2013, set the range from 0 to 1380844800000.0' + 'If the axis `type` is *log*, then you must take the log of your', + 'desired range (e.g. to set the range from 1 to 100,', + 'set the range from 0 to 2).', + 'If the axis `type` is *date*, it should be date strings,', + 'like date data, though Date objects and unix milliseconds', + 'will be accepted and converted to strings.', + 'If the axis `type` is *category*, it should be numbers,', + 'using the scale where each category is assigned a serial', + 'number from zero in the order it appears.' ].join(' ') }, @@ -133,35 +137,42 @@ module.exports = { ].join(' ') }, tick0: { - valType: 'number', - dflt: 0, + valType: 'any', role: 'style', description: [ 'Sets the placement of the first tick on this axis.', 'Use with `dtick`.', 'If the axis `type` is *log*, then you must take the log of your starting tick', - '(e.g. to set the starting tick to 100, set the `tick0` to 2).', - 'If the axis `type` is *date*, then you must convert the date to unix time in milliseconds', - '(the number of milliseconds since January 1st, 1970).', - 'For example, to set the starting tick to', - 'November 4th, 2013, set the range to 1380844800000.0.' + '(e.g. to set the starting tick to 100, set the `tick0` to 2)', + 'except when `dtick`=*L* (see `dtick` for more info).', + 'If the axis `type` is *date*, it should be a date string, like date data.', + 'If the axis `type` is *category*, it should be a number, using the scale where', + 'each category is assigned a serial number from zero in the order it appears.' ].join(' ') }, dtick: { valType: 'any', - dflt: 1, role: 'style', description: [ - 'Sets the step in-between ticks on this axis', - 'Use with `tick0`.', + 'Sets the step in-between ticks on this axis. Use with `tick0`.', + 'Must be a positive number, or special strings available to *log* and *date* axes.', 'If the axis `type` is *log*, then ticks are set every 10^(n*dtick) where n', 'is the tick number. For example,', 'to set a tick mark at 1, 10, 100, 1000, ... set dtick to 1.', 'To set tick marks at 1, 100, 10000, ... set dtick to 2.', 'To set tick marks at 1, 5, 25, 125, 625, 3125, ... set dtick to log_10(5), or 0.69897000433.', + '*log* has several special values; *L*, where `f` is a positive number,', + 'gives ticks linearly spaced in value (but not position).', + 'For example `tick0` = 0.1, `dtick` = *L0.5* will put ticks at 0.1, 0.6, 1.1, 1.6 etc.', + 'To show powers of 10 plus small digits between, use *D1* (all digits) or *D2* (only 2 and 5).', + '`tick0` is ignored for *D1* and *D2*.', 'If the axis `type` is *date*, then you must convert the time to milliseconds.', 'For example, to set the interval between ticks to one day,', - 'set `dtick` to 86400000.0.' + 'set `dtick` to 86400000.0.', + '*date* also has special values *M* gives ticks spaced by a number of months.', + '`n` must be a positive integer.', + 'To set ticks on the 15th of every third month, set `tick0` to *2000-01-15* and `dtick` to *M3*.', + 'To set ticks every 4 years, set `dtick` to *M48*' ].join(' ') }, tickvals: { @@ -318,11 +329,14 @@ module.exports = { dflt: '', role: 'style', description: [ - 'Sets the tick label formatting rule using the', - 'python/d3 number formatting language.', - 'See https://github.com/mbostock/d3/wiki/Formatting#numbers', - 'or https://docs.python.org/release/3.1.3/library/string.html#formatspec', - 'for more info.' + 'Sets the tick label formatting rule using d3 formatting mini-languages', + 'which are very similar to those in Python. For numbers, see:', + 'https://github.com/d3/d3-format/blob/master/README.md#locale_format', + 'And for dates see:', + 'https://github.com/d3/d3-time-format/blob/master/README.md#locale_format', + 'We add one item to d3\'s date formatter: *%{n}f* for fractional seconds', + 'with n digits. For example, *2016-10-13 09:15:23.456* with tickformat', + '*%H~%M~%S.%2f* would display *09~15~23.46*' ].join(' ') }, hoverformat: { @@ -330,11 +344,14 @@ module.exports = { dflt: '', role: 'style', description: [ - 'Sets the hover text formatting rule for data values on this axis,', - 'using the python/d3 number formatting language.', - 'See https://github.com/mbostock/d3/wiki/Formatting#numbers', - 'or https://docs.python.org/release/3.1.3/library/string.html#formatspec', - 'for more info.' + 'Sets the hover text formatting rule using d3 formatting mini-languages', + 'which are very similar to those in Python. For numbers, see:', + 'https://github.com/d3/d3-format/blob/master/README.md#locale_format', + 'And for dates see:', + 'https://github.com/d3/d3-time-format/blob/master/README.md#locale_format', + 'We add one item to d3\'s date formatter: *%{n}f* for fractional seconds', + 'with n digits. For example, *2016-10-13 09:15:23.456* with tickformat', + '*%H~%M~%S.%2f* would display *09~15~23.46*' ].join(' ') }, // lines and grids diff --git a/src/plots/cartesian/set_convert.js b/src/plots/cartesian/set_convert.js index b5e1b0258e4..534e380c7a2 100644 --- a/src/plots/cartesian/set_convert.js +++ b/src/plots/cartesian/set_convert.js @@ -13,27 +13,38 @@ var d3 = require('d3'); var isNumeric = require('fast-isnumeric'); var Lib = require('../../lib'); +var numConstants = require('../../constants/numerical'); +var FP_SAFE = numConstants.FP_SAFE; +var BADNUM = numConstants.BADNUM; var constants = require('./constants'); -var cleanDatum = require('./clean_datum'); var axisIds = require('./axis_ids'); /** - * Define the conversion functions for an axis data is used in 4 ways: + * Define the conversion functions for an axis data is used in 5 ways: * * d: data, in whatever form it's provided * c: calcdata: turned into numbers, but not linearized - * l: linearized - same as c except for log axes (and other - * mappings later?) this is used by ranges, and when we - * need to know if it's *possible* to show some data on - * this axis, without caring about the current range + * l: linearized - same as c except for log axes (and other nonlinear + * mappings later?) this is used when we need to know if it's + * *possible* to show some data on this axis, without caring about + * the current range * p: pixel value - mapped to the screen with current size and zoom + * r: ranges, tick0, and annotation positions match one of the above + * but are handled differently for different types: + * - linear and date: data format (d) + * - category: calcdata format (c), and will stay that way because + * the data format has no continuous mapping + * - log: linearized (l) format + * TODO: in v2.0 we plan to change it to data format. At that point + * shapes will work the same way as ranges, tick0, and annotations + * so they can use this conversion too. * - * Creates/updates these conversion functions + * Creates/updates these conversion functions, as well as cleaner functions: + * ax.d2d and ax.clean2r * also clears the autorange bounds ._min and ._max - * and the autotick constraints ._minDtick, ._forceTick0, - * and looks for date ranges that aren't yet in numeric format + * and the autotick constraints ._minDtick, ._forceTick0 */ module.exports = function setConvert(ax) { @@ -53,20 +64,119 @@ module.exports = function setConvert(ax) { return 0.5 * (r0 + r1 - 3 * clipMult * Math.abs(r0 - r1)); } - else return constants.BADNUM; + else return BADNUM; + } + + function fromLog(v) { + return Math.pow(10, v); + } + + function num(v) { + if(!isNumeric(v)) return BADNUM; + v = Number(v); + if(v < -FP_SAFE || v > FP_SAFE) return BADNUM; + return isNumeric(v) ? Number(v) : BADNUM; } - function fromLog(v) { return Math.pow(10, v); } - function num(v) { return isNumeric(v) ? Number(v) : constants.BADNUM; } ax.c2l = (ax.type === 'log') ? toLog : num; ax.l2c = (ax.type === 'log') ? fromLog : num; ax.l2d = function(v) { return ax.c2d(ax.l2c(v)); }; ax.p2d = function(v) { return ax.l2d(ax.p2l(v)); }; + /* + * fn to make sure range is a couplet of valid & distinct values + * keep numbers away from the limits of floating point numbers, + * and dates away from the ends of our date system (+/- 9999 years) + * + * optional param rangeAttr: operate on a different attribute, like + * ax._r, rather than ax.range + */ + ax.cleanRange = function(rangeAttr) { + if(!rangeAttr) rangeAttr = 'range'; + var range = ax[rangeAttr], + axLetter = (ax._id || 'x').charAt(0), + i, dflt; + + if(ax.type === 'date') dflt = constants.DFLTRANGEDATE; + else if(axLetter === 'y') dflt = constants.DFLTRANGEY; + else dflt = constants.DFLTRANGEX; + + // make sure we don't later mutate the defaults + dflt = dflt.slice(); + + if(!range || range.length !== 2) { + ax[rangeAttr] = dflt; + return; + } + + if(ax.type === 'date') { + // check if milliseconds or js date objects are provided for range + // and convert to date strings + range[0] = Lib.cleanDate(range[0]); + range[1] = Lib.cleanDate(range[1]); + } + + for(i = 0; i < 2; i++) { + if(ax.type === 'date') { + if(!Lib.isDateTime(range[i])) { + ax[rangeAttr] = dflt; + break; + } + + if(range[i] < Lib.MIN_MS) range[i] = Lib.MIN_MS; + if(range[i] > Lib.MAX_MS) range[i] = Lib.MAX_MS; + + if(ax.r2l(range[0]) === ax.r2l(range[1])) { + // split by +/- 1 second + var linCenter = Lib.constrain(ax.r2l(range[0]), + Lib.MIN_MS + 1000, Lib.MAX_MS - 1000); + range[0] = ax.l2r(linCenter - 1000); + range[1] = ax.l2r(linCenter + 1000); + break; + } + } + else { + if(!isNumeric(range[i])) { + if(isNumeric(range[1 - i])) { + range[i] = range[1 - i] * (i ? 10 : 0.1); + } + else { + ax[rangeAttr] = dflt; + break; + } + } + + if(range[i] < -FP_SAFE) range[i] = -FP_SAFE; + else if(range[i] > FP_SAFE) range[i] = FP_SAFE; + + if(range[0] === range[1]) { + // somewhat arbitrary: split by 1 or 1ppm, whichever is bigger + var inc = Math.max(1, Math.abs(range[0] * 1e-6)); + range[0] -= inc; + range[1] += inc; + } + } + } + }; + + // find the range value at the specified (linear) fraction of the axis + ax.fraction2r = function(v) { + var rl0 = ax.r2l(ax.range[0]), + rl1 = ax.r2l(ax.range[1]); + return ax.l2r(rl0 + v * (rl1 - rl0)); + }; + + // find the fraction of the range at the specified range value + ax.r2fraction = function(v) { + var rl0 = ax.r2l(ax.range[0]), + rl1 = ax.r2l(ax.range[1]); + return (ax.r2l(v) - rl0) / (rl1 - rl0); + }; + // set scaling to pixels ax.setScale = function(usePrivateRange) { var gs = ax._gd._fullLayout._size, - i; + axLetter = ax._id.charAt(0); // TODO cleaner way to handle this case if(!ax._categories) ax._categories = []; @@ -82,40 +192,23 @@ module.exports = function setConvert(ax) { // issue if we transform the drawn layer *and* use the new axis range to // draw the data. This allows us to construct setConvert using the pre- // interaction values of the range: - var range = (usePrivateRange && ax._r) ? ax._r : ax.range; + var rangeAttr = (usePrivateRange && ax._r) ? '_r' : 'range'; + ax.cleanRange(rangeAttr); - // make sure we have a range (linearized data values) - // and that it stays away from the limits of javascript numbers - if(!range || range.length !== 2 || range[0] === range[1]) { - range = [-1, 1]; - } - for(i = 0; i < 2; i++) { - if(!isNumeric(range[i])) { - range[i] = isNumeric(range[1 - i]) ? - (range[1 - i] * (i ? 10 : 0.1)) : - (i ? 1 : -1); - } - - if(range[i] < -(Number.MAX_VALUE / 2)) { - range[i] = -(Number.MAX_VALUE / 2); - } - else if(range[i] > Number.MAX_VALUE / 2) { - range[i] = Number.MAX_VALUE / 2; - } + var rl0 = ax.r2l(ax[rangeAttr][0]), + rl1 = ax.r2l(ax[rangeAttr][1]); - } - - if(ax._id.charAt(0) === 'y') { + if(axLetter === 'y') { ax._offset = gs.t + (1 - ax.domain[1]) * gs.h; ax._length = gs.h * (ax.domain[1] - ax.domain[0]); - ax._m = ax._length / (range[0] - range[1]); - ax._b = -ax._m * range[1]; + ax._m = ax._length / (rl0 - rl1); + ax._b = -ax._m * rl1; } else { ax._offset = gs.l + ax.domain[0] * gs.w; ax._length = gs.w * (ax.domain[1] - ax.domain[0]); - ax._m = ax._length / (range[1] - range[0]); - ax._b = -ax._m * range[0]; + ax._m = ax._length / (rl1 - rl0); + ax._b = -ax._m * rl0; } if(!isFinite(ax._m) || !isFinite(ax._b)) { @@ -128,7 +221,7 @@ module.exports = function setConvert(ax) { }; ax.l2p = function(v) { - if(!isNumeric(v)) return constants.BADNUM; + if(!isNumeric(v)) return BADNUM; // include 2 fractional digits on pixel, for PDF zooming etc return d3.round(ax._b + ax._m * v, 2); @@ -139,42 +232,59 @@ module.exports = function setConvert(ax) { ax.c2p = function(v, clip) { return ax.l2p(ax.c2l(v, clip)); }; ax.p2c = function(px) { return ax.l2c(ax.p2l(px)); }; + // clip doesn't do anything here yet, but in v2.0 when log axes get + // refactored it will... so including it now so we don't forget. + ax.r2p = function(v, clip) { return ax.l2p(ax.r2l(v, clip)); }; + ax.p2r = function(px) { return ax.l2r(ax.p2l(px)); }; + if(['linear', 'log', '-'].indexOf(ax.type) !== -1) { ax.c2d = num; - ax.d2c = function(v) { - v = cleanDatum(v); - return isNumeric(v) ? Number(v) : constants.BADNUM; - }; - ax.d2l = function(v, clip) { - if(ax.type === 'log') return ax.c2l(ax.d2c(v), clip); - else return ax.d2c(v); - }; + ax.d2c = Lib.cleanNumber; + if(ax.type === 'log') { + ax.d2l = function(v, clip) { + return ax.c2l(ax.d2c(v), clip); + }; + ax.d2r = ax.d2l; + ax.r2d = ax.l2d; + } + else { + ax.d2l = Lib.cleanNumber; + ax.d2r = Lib.cleanNumber; + ax.r2d = num; + } + ax.r2l = num; + ax.l2r = num; } else if(ax.type === 'date') { - ax.c2d = function(v) { - return isNumeric(v) ? Lib.ms2DateTime(v) : constants.BADNUM; - }; + ax.c2d = Lib.ms2DateTime; ax.d2c = function(v) { - return (isNumeric(v)) ? Number(v) : Lib.dateTime2ms(v); + // NOTE: Changed this behavior: previously we took any numeric value + // to be a ms, even if it was a string that could be a bare year. + // Now we convert it as a date if at all possible, and only try + // as ms if that fails. + var ms = Lib.dateTime2ms(v); + if(ms === BADNUM) { + if(isNumeric(v)) ms = Number(v); + else return BADNUM; + } + return Lib.constrain(ms, Lib.MIN_MS, Lib.MAX_MS); }; ax.d2l = ax.d2c; - - // check if date strings or js date objects are provided for range - // and convert to ms - if(ax.range && ax.range.length > 1) { - try { - var ar1 = ax.range.map(Lib.dateTime2ms); - if(!isNumeric(ax.range[0]) && isNumeric(ar1[0])) { - ax.range[0] = ar1[0]; - } - if(!isNumeric(ax.range[1]) && isNumeric(ar1[1])) { - ax.range[1] = ar1[1]; - } - } - catch(e) { Lib.error(e, ax.range); } - } + ax.r2l = ax.d2c; + ax.l2r = ax.c2d; + ax.d2r = Lib.identity; + ax.r2d = Lib.identity; + ax.cleanr = function(v) { + /* + * If v is already a date string this is a noop, but running it + * through d2c and back validates the value: + * normalizes Date objects, milliseconds, and out-of-bounds dates + * so we always end up with either a clean date string or BADNUM + */ + return ax.c2d(ax.d2c(v)); + }; } else if(ax.type === 'category') { @@ -197,38 +307,42 @@ module.exports = function setConvert(ax) { } var c = ax._categories.indexOf(v); - return c === -1 ? constants.BADNUM : c; + return c === -1 ? BADNUM : c; }; ax.d2l = ax.d2c; + ax.r2l = num; + ax.l2r = num; + ax.d2r = ax.d2c; + ax.r2d = ax.c2d; } // makeCalcdata: takes an x or y array and converts it // to a position on the axis object "ax" // inputs: - // tdc - a data object from td.data - // axletter - a string, either 'x' or 'y', for which item + // trace - a data object from gd.data + // axLetter - a string, either 'x' or 'y', for which item // to convert (TODO: is this now always the same as // the first letter of ax._id?) // in case the expected data isn't there, make a list of // integers based on the opposite data - ax.makeCalcdata = function(tdc, axletter) { + ax.makeCalcdata = function(trace, axLetter) { var arrayIn, arrayOut, i; - if(axletter in tdc) { - arrayIn = tdc[axletter]; + if(axLetter in trace) { + arrayIn = trace[axLetter]; arrayOut = new Array(arrayIn.length); for(i = 0; i < arrayIn.length; i++) arrayOut[i] = ax.d2c(arrayIn[i]); } else { - var v0 = ((axletter + '0') in tdc) ? - ax.d2c(tdc[axletter + '0']) : 0, - dv = (tdc['d' + axletter]) ? - Number(tdc['d' + axletter]) : 1; + var v0 = ((axLetter + '0') in trace) ? + ax.d2c(trace[axLetter + '0']) : 0, + dv = (trace['d' + axLetter]) ? + Number(trace['d' + axLetter]) : 1; // the opposing data, for size if we have x and dx etc - arrayIn = tdc[{x: 'y', y: 'x'}[axletter]]; + arrayIn = trace[{x: 'y', y: 'x'}[axLetter]]; arrayOut = new Array(arrayIn.length); for(i = 0; i < arrayIn.length; i++) arrayOut[i] = v0 + i * dv; @@ -243,6 +357,6 @@ module.exports = function setConvert(ax) { ax._max = []; // and for bar charts and box plots: reset forced minimum tick spacing - ax._minDtick = null; - ax._forceTick0 = null; + delete ax._minDtick; + delete ax._forceTick0; }; diff --git a/src/plots/cartesian/tick_value_defaults.js b/src/plots/cartesian/tick_value_defaults.js index b51994b5ce7..f441cb5e055 100644 --- a/src/plots/cartesian/tick_value_defaults.js +++ b/src/plots/cartesian/tick_value_defaults.js @@ -10,6 +10,8 @@ 'use strict'; var isNumeric = require('fast-isnumeric'); +var Lib = require('../../lib'); +var ONEDAY = require('../../constants/numerical').ONEDAY; module.exports = function handleTickValueDefaults(containerIn, containerOut, coerce, axType) { @@ -21,15 +23,56 @@ module.exports = function handleTickValueDefaults(containerIn, containerOut, coe } if(Array.isArray(containerIn.tickvals)) tickmodeDefault = 'array'; - else if(containerIn.dtick && isNumeric(containerIn.dtick)) { + else if(containerIn.dtick) { tickmodeDefault = 'linear'; } var tickmode = coerce('tickmode', tickmodeDefault); if(tickmode === 'auto') coerce('nticks'); else if(tickmode === 'linear') { - coerce('tick0'); - coerce('dtick'); + // dtick is usually a positive number, but there are some + // special strings available for log or date axes + // default is 1 day for dates, otherwise 1 + var dtickDflt = (axType === 'date') ? ONEDAY : 1; + var dtick = coerce('dtick', dtickDflt); + if(isNumeric(dtick)) { + containerOut.dtick = (dtick > 0) ? Number(dtick) : dtickDflt; + } + else if(typeof dtick !== 'string') { + containerOut.dtick = dtickDflt; + } + else { + // date and log special cases are all one character plus a number + var prefix = dtick.charAt(0), + dtickNum = dtick.substr(1); + + dtickNum = isNumeric(dtickNum) ? Number(dtickNum) : 0; + if((dtickNum <= 0) || !( + // "M" gives ticks every (integer) n months + (axType === 'date' && prefix === 'M' && dtickNum === Math.round(dtickNum)) || + // "L" gives ticks linearly spaced in data (not in position) every (float) f + (axType === 'log' && prefix === 'L') || + // "D1" gives powers of 10 with all small digits between, "D2" gives only 2 and 5 + (axType === 'log' && prefix === 'D' && (dtickNum === 1 || dtickNum === 2)) + )) { + containerOut.dtick = dtickDflt; + } + } + + // tick0 can have different valType for different axis types, so + // validate that now. Also for dates, change milliseconds to date strings + var tick0Dflt = (axType === 'date') ? '2000-01-01' : 0; + var tick0 = coerce('tick0', tick0Dflt); + if(axType === 'date') { + containerOut.tick0 = Lib.cleanDate(tick0, tick0Dflt); + } + // Aside from date axes, dtick must be numeric; D1 and D2 modes ignore tick0 entirely + else if(isNumeric(tick0) && dtick !== 'D1' && dtick !== 'D2') { + containerOut.tick0 = Number(tick0); + } + else { + containerOut.tick0 = tick0Dflt; + } } else { var tickvals = coerce('tickvals'); diff --git a/src/plots/gl2d/camera.js b/src/plots/gl2d/camera.js index 50fd45eaa78..80f56fefccf 100644 --- a/src/plots/gl2d/camera.js +++ b/src/plots/gl2d/camera.js @@ -38,8 +38,7 @@ function createCamera(scene) { } result.mouseListener = mouseChange(element, function(buttons, x, y) { - var xrange = scene.xaxis.range, - yrange = scene.yaxis.range, + var dataBox = scene.calcDataBox(), viewBox = plot.viewBox; var lastX = result.lastPos[0], @@ -51,14 +50,15 @@ function createCamera(scene) { // mouseChange gives y about top; convert to about bottom y = (viewBox[3] - viewBox[1]) - y; - function updateRange(range, start, end) { + function updateRange(i0, start, end) { var range0 = Math.min(start, end), range1 = Math.max(start, end); if(range0 !== range1) { - range[0] = range0; - range[1] = range1; - result.dataBox = range; + dataBox[i0] = range0; + dataBox[i0 + 2] = range1; + result.dataBox = dataBox; + scene.setRanges(dataBox); } else { scene.selectBox.selectBox = [0, 0, 1, 1]; @@ -70,11 +70,11 @@ function createCamera(scene) { case 'zoom': if(buttons) { var dataX = x / - (viewBox[2] - viewBox[0]) * (xrange[1] - xrange[0]) + - xrange[0]; + (viewBox[2] - viewBox[0]) * (dataBox[2] - dataBox[0]) + + dataBox[0]; var dataY = y / - (viewBox[3] - viewBox[1]) * (yrange[1] - yrange[0]) + - yrange[0]; + (viewBox[3] - viewBox[1]) * (dataBox[3] - dataBox[1]) + + dataBox[1]; if(!result.boxEnabled) { result.boxStart[0] = dataX; @@ -87,8 +87,8 @@ function createCamera(scene) { result.boxEnabled = true; } else if(result.boxEnabled) { - updateRange(xrange, result.boxStart[0], result.boxEnd[0]); - updateRange(yrange, result.boxStart[1], result.boxEnd[1]); + updateRange(0, result.boxStart[0], result.boxEnd[0]); + updateRange(1, result.boxStart[1], result.boxEnd[1]); unSetAutoRange(); result.boxEnabled = false; } @@ -98,15 +98,17 @@ function createCamera(scene) { result.boxEnabled = false; if(buttons) { - var dx = (lastX - x) * (xrange[1] - xrange[0]) / + var dx = (lastX - x) * (dataBox[2] - dataBox[0]) / (plot.viewBox[2] - plot.viewBox[0]); - var dy = (lastY - y) * (yrange[1] - yrange[0]) / + var dy = (lastY - y) * (dataBox[3] - dataBox[1]) / (plot.viewBox[3] - plot.viewBox[1]); - xrange[0] += dx; - xrange[1] += dx; - yrange[0] += dy; - yrange[1] += dy; + dataBox[0] += dx; + dataBox[2] += dx; + dataBox[1] += dy; + dataBox[3] += dy; + + scene.setRanges(dataBox); result.lastInputTime = Date.now(); unSetAutoRange(); @@ -120,8 +122,7 @@ function createCamera(scene) { }); result.wheelListener = mouseWheel(element, function(dx, dy) { - var xrange = scene.xaxis.range, - yrange = scene.yaxis.range, + var dataBox = scene.calcDataBox(), viewBox = plot.viewBox; var lastX = result.lastPos[0], @@ -135,16 +136,18 @@ function createCamera(scene) { var scale = Math.exp(0.1 * dy / (viewBox[3] - viewBox[1])); var cx = lastX / - (viewBox[2] - viewBox[0]) * (xrange[1] - xrange[0]) + - xrange[0]; + (viewBox[2] - viewBox[0]) * (dataBox[2] - dataBox[0]) + + dataBox[0]; var cy = lastY / - (viewBox[3] - viewBox[1]) * (yrange[1] - yrange[0]) + - yrange[0]; + (viewBox[3] - viewBox[1]) * (dataBox[3] - dataBox[1]) + + dataBox[1]; + + dataBox[0] = (dataBox[0] - cx) * scale + cx; + dataBox[2] = (dataBox[2] - cx) * scale + cx; + dataBox[1] = (dataBox[1] - cy) * scale + cy; + dataBox[3] = (dataBox[3] - cy) * scale + cy; - xrange[0] = (xrange[0] - cx) * scale + cx; - xrange[1] = (xrange[1] - cx) * scale + cx; - yrange[0] = (yrange[0] - cy) * scale + cy; - yrange[1] = (yrange[1] - cy) * scale + cy; + scene.setRanges(dataBox); result.lastInputTime = Date.now(); unSetAutoRange(); diff --git a/src/plots/gl2d/scene2d.js b/src/plots/gl2d/scene2d.js index 3fe459b6131..b8e54299751 100644 --- a/src/plots/gl2d/scene2d.js +++ b/src/plots/gl2d/scene2d.js @@ -310,14 +310,9 @@ var relayoutCallback = function(scene) { }; proto.cameraChanged = function() { - var camera = this.camera, - xrange = this.xaxis.range, - yrange = this.yaxis.range; + var camera = this.camera; - this.glplot.setDataBox([ - xrange[0], yrange[0], - xrange[1], yrange[1] - ]); + this.glplot.setDataBox(this.calcDataBox()); var nextTicks = this.computeTickMarks(); var curTicks = this.glplotOptions.ticks; @@ -415,9 +410,7 @@ proto.plot = function(fullData, calcData, fullLayout) { options.ticks = this.computeTickMarks(); - var xrange = this.xaxis.range; - var yrange = this.yaxis.range; - options.dataBox = [xrange[0], yrange[0], xrange[1], yrange[1]]; + options.dataBox = this.calcDataBox(); options.merge(fullLayout); glplot.update(options); @@ -426,6 +419,27 @@ proto.plot = function(fullData, calcData, fullLayout) { this.glplot.draw(); }; +proto.calcDataBox = function() { + var xaxis = this.xaxis, + yaxis = this.yaxis, + xrange = xaxis.range, + yrange = yaxis.range, + xr2l = xaxis.r2l, + yr2l = yaxis.r2l; + + return [xr2l(xrange[0]), yr2l(yrange[0]), xr2l(xrange[1]), yr2l(yrange[1])]; +}; + +proto.setRanges = function(dataBox) { + var xaxis = this.xaxis, + yaxis = this.yaxis, + xl2r = xaxis.l2r, + yl2r = yaxis.l2r; + + xaxis.range = [xl2r(dataBox[0]), xl2r(dataBox[2])]; + yaxis.range = [yl2r(dataBox[1]), yl2r(dataBox[3])]; +}; + proto.updateTraces = function(fullData, calcData) { var traceIds = Object.keys(this.traces); var i, j, fullTrace; diff --git a/src/plots/plots.js b/src/plots/plots.js index 4034d8dc40d..fc430995e08 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -1382,9 +1382,7 @@ plots.graphJson = function(gd, dataonly, mode, output, useDefaults) { // convert native dates to date strings... // mostly for external users exporting to plotly - if(d && d.getTime) { - return Lib.ms2DateTime(d); - } + if(Lib.isJSDate(d)) return Lib.ms2DateTime(+d); return d; } diff --git a/src/snapshot/cloneplot.js b/src/snapshot/cloneplot.js index d8f076b0d7f..25a389306df 100644 --- a/src/snapshot/cloneplot.js +++ b/src/snapshot/cloneplot.js @@ -124,11 +124,12 @@ module.exports = function clonePlot(graphObj, options) { } } - var td = document.createElement('div'); - if(options.tileClass) td.className = options.tileClass; + var gd = document.createElement('div'); + if(options.tileClass) gd.className = options.tileClass; var plotTile = { - td: td, + gd: gd, + td: gd, // for external (image server) compatibility layout: newLayout, data: newData, config: { @@ -148,8 +149,8 @@ module.exports = function clonePlot(graphObj, options) { plotTile.config.setBackground = options.setBackground || 'opaque'; } - // attaching the default Layout the td, so you can grab it later - plotTile.td.defaultLayout = cloneLayoutOverride(options.tileClass); + // attaching the default Layout the gd, so you can grab it later + plotTile.gd.defaultLayout = cloneLayoutOverride(options.tileClass); return plotTile; }; diff --git a/src/snapshot/toimage.js b/src/snapshot/toimage.js index 9fc8eb75b5a..76386f4ac83 100644 --- a/src/snapshot/toimage.js +++ b/src/snapshot/toimage.js @@ -30,7 +30,7 @@ function toImage(gd, opts) { var ev = new EventEmitter(); var clone = clonePlot(gd, {format: 'png'}); - var clonedGd = clone.td; + var clonedGd = clone.gd; // put the cloned div somewhere off screen before attaching to DOM clonedGd.style.position = 'absolute'; diff --git a/src/traces/ohlc/transform.js b/src/traces/ohlc/transform.js index bb39320d76d..93ea567cea9 100644 --- a/src/traces/ohlc/transform.js +++ b/src/traces/ohlc/transform.js @@ -134,18 +134,28 @@ exports.calcTransform = function calcTransform(gd, trace, opts) { y = [], textOut = []; - var getXItem = trace._fullInput.x ? - function(i) { return xa.d2c(trace.x[i]); } : - function(i) { return i; }; - - var getTextItem = Array.isArray(textIn) ? - function(i) { return textIn[i] || ''; } : - function() { return textIn; }; - - var appendX = function(i) { - var v = getXItem(i); - x.push(v - tickWidth, v, v, v, v, v + tickWidth, null); - }; + var appendX; + if(trace._fullInput.x) { + appendX = function(i) { + var xi = trace.x[i], + xcalc = xa.d2c(xi); + + x.push( + xa.c2d(xcalc - tickWidth), + xi, xi, xi, xi, + xa.c2d(xcalc + tickWidth), + null); + }; + } + else { + appendX = function(i) { + x.push( + i - tickWidth, + i, i, i, i, + i + tickWidth, + null); + }; + } var appendY = function(o, h, l, c) { y.push(o, o, h, l, c, c, null); @@ -161,6 +171,10 @@ exports.calcTransform = function calcTransform(gd, trace, opts) { hasY = hasAll || hoverParts.indexOf('y') !== -1, hasText = hasAll || hoverParts.indexOf('text') !== -1; + var getTextItem = Array.isArray(textIn) ? + function(i) { return textIn[i] || ''; } : + function() { return textIn; }; + var appendText = function(i, o, h, l, c) { var t = []; diff --git a/src/traces/scatter/line_points.js b/src/traces/scatter/line_points.js index 390242a1fd7..4fc789180dc 100644 --- a/src/traces/scatter/line_points.js +++ b/src/traces/scatter/line_points.js @@ -9,7 +9,7 @@ 'use strict'; -var Axes = require('../../plots/cartesian/axes'); +var BADNUM = require('../../constants/numerical').BADNUM; module.exports = function linePoints(d, opts) { @@ -20,7 +20,6 @@ module.exports = function linePoints(d, opts) { baseTolerance = opts.baseTolerance, linear = opts.linear, segments = [], - badnum = Axes.BADNUM, minTolerance = 0.2, // fraction of tolerance "so close we don't even consider it a new point" pts = new Array(d.length), pti = 0, @@ -57,7 +56,7 @@ module.exports = function linePoints(d, opts) { function getPt(index) { var x = xa.c2p(d[index].x), y = ya.c2p(d[index].y); - if(x === badnum || y === badnum) return false; + if(x === BADNUM || y === BADNUM) return false; return [x, y]; } diff --git a/src/traces/scatter/plot.js b/src/traces/scatter/plot.js index 8a31a683ff5..45a3b180b4e 100644 --- a/src/traces/scatter/plot.js +++ b/src/traces/scatter/plot.js @@ -494,8 +494,8 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element, transition function selectMarkers(gd, idx, plotinfo, cdscatter, cdscatterAll) { var xa = plotinfo.xaxis, ya = plotinfo.yaxis, - xr = d3.extent(xa.range.map(xa.l2c)), - yr = d3.extent(ya.range.map(ya.l2c)); + xr = d3.extent(xa.range.map(xa.r2l).map(xa.l2c)), + yr = d3.extent(ya.range.map(ya.r2l).map(ya.l2c)); var trace = cdscatter[0].trace; if(!subTypes.hasMarkers(trace)) return; diff --git a/test/image/baselines/29.png b/test/image/baselines/29.png index a5bed7f5221..0faec1aead8 100644 Binary files a/test/image/baselines/29.png and b/test/image/baselines/29.png differ diff --git a/test/image/baselines/annotations-autorange.png b/test/image/baselines/annotations-autorange.png index 064f9272687..07df4be6243 100644 Binary files a/test/image/baselines/annotations-autorange.png and b/test/image/baselines/annotations-autorange.png differ diff --git a/test/image/baselines/layout_image.png b/test/image/baselines/layout_image.png index 8399bc3bafe..def47ebab0f 100644 Binary files a/test/image/baselines/layout_image.png and b/test/image/baselines/layout_image.png differ diff --git a/test/image/baselines/shapes.png b/test/image/baselines/shapes.png index e92907c9e06..51456a8d92d 100644 Binary files a/test/image/baselines/shapes.png and b/test/image/baselines/shapes.png differ diff --git a/test/image/mocks/annotations-autorange.json b/test/image/mocks/annotations-autorange.json index 9f2db2be978..6ec2838daad 100644 --- a/test/image/mocks/annotations-autorange.json +++ b/test/image/mocks/annotations-autorange.json @@ -20,7 +20,8 @@ "anchor":"y2", "mirror":true, "zeroline":false, - "showline":true + "showline":true, + "type": "date" }, "xaxis3":{ "domain":[0.7,1], @@ -38,7 +39,8 @@ "anchor":"x2", "mirror":true, "zeroline":false, - "showline":true + "showline":true, + "type": "log" }, "yaxis3":{ "anchor":"x3", @@ -54,10 +56,10 @@ {"ay":0,"ax":-50,"x":2,"y":1.5,"text":"Right"}, {"x":1.5,"y":2,"text":"Top","ay":50,"ax":0}, {"x":1.5,"y":1,"text":"Bottom","ay":-50,"ax":0}, - {"xref":"x2","yref":"y2","text":"From left","y":1.5,"ax":-50,"ay":0,"x":1}, - {"xref":"x2","yref":"y2","text":"From right","y":1.5,"x":2,"ay":0,"ax":50}, - {"xref":"x2","yref":"y2","text":"From top","y":2,"ax":0,"ay":-50,"x":1.5}, - {"xref":"x2","yref":"y2","text":"From Bottom","y":1,"ax":0,"ay":50,"x":1.5}, + {"xref":"x2","yref":"y2","text":"From left","y":2,"ax":-50,"ay":0,"x":"2001-01-01"}, + {"xref":"x2","yref":"y2","text":"From right","y":2,"x":"2001-03-01","ay":0,"ax":50}, + {"xref":"x2","yref":"y2","text":"From top","y":3,"ax":0,"ay":-50,"x":"2001-02-01"}, + {"xref":"x2","yref":"y2","text":"From Bottom","y":1,"ax":0,"ay":50,"x":"2001-02-01"}, {"xref":"x3","yref":"y3","text":"Left
no
arrow","y":1.5,"x":1,"showarrow":false}, {"xref":"x3","yref":"y3","text":"Right
no
arrow","y":1.5,"x":2,"showarrow":false}, {"xref":"x3","yref":"y3","text":"Bottom
no
arrow","y":1,"x":1.5,"showarrow":false}, diff --git a/test/image/mocks/layout_image.json b/test/image/mocks/layout_image.json index d993e8f61dd..200fb0f9c51 100644 --- a/test/image/mocks/layout_image.json +++ b/test/image/mocks/layout_image.json @@ -4,17 +4,22 @@ "x": [1,2,3], "y": [1,2,3] }, { - "x": [1,2,3], - "y": [1,2,3], + "x": ["2001-01-01","2002-01-01","2003-01-01"], + "y": [10,100,1000], + "xaxis": "x2", "yaxis": "y2" } ], "layout": { + "xaxis2": { + "anchor": "y2" + }, "yaxis": { - "domain": [0, 0.5] + "domain": [0, 0.45] }, "yaxis2": { - "domain": [0.5, 1] + "domain": [0.55, 1], + "type": "log" }, "images": [ { @@ -41,11 +46,11 @@ }, { "source": "https://images.plot.ly/language-icons/api-home/r-logo.png", - "xref": "x", + "xref": "x2", "yref": "y2", - "x": 1, + "x": "2001-01-01", "y": 3, - "sizex": 2, + "sizex": 63072000000, "sizey": 2, "sizing": "stretch", "opacity": 0.4, diff --git a/test/jasmine/tests/annotations_test.js b/test/jasmine/tests/annotations_test.js index 4816097dea5..bda05520697 100644 --- a/test/jasmine/tests/annotations_test.js +++ b/test/jasmine/tests/annotations_test.js @@ -4,6 +4,7 @@ var Plotly = require('@lib/index'); var Plots = require('@src/plots/plots'); var Lib = require('@src/lib'); var Dates = require('@src/lib/dates'); +var Axes = require('@src/plots/cartesian/axes'); var d3 = require('d3'); var customMatchers = require('../assets/custom_matchers'); @@ -24,17 +25,26 @@ describe('Test annotations', function() { expect(annotationDefaults.annotations[0].ayref).toEqual('pixel'); }); - it('should convert ax/ay date coordinates to milliseconds if tail is in axis terms and axis is a date', function() { - var annotationOut = { xaxis: { type: 'date', range: ['2000-01-01', '2016-01-01'] }}; - annotationOut._has = Plots._hasPlotType.bind(annotationOut); - - var annotationIn = { - annotations: [{ showarrow: true, axref: 'x', ayref: 'y', x: '2008-07-01', ax: '2004-07-01', y: 0, ay: 50}] + it('should convert ax/ay date coordinates to date string if tail is in milliseconds and axis is a date', function() { + var layoutOut = { xaxis: { type: 'date', range: ['2000-01-01', '2016-01-01'] }}; + layoutOut._has = Plots._hasPlotType.bind(layoutOut); + Axes.setConvert(layoutOut.xaxis); + + var layoutIn = { + annotations: [{ + showarrow: true, + axref: 'x', + ayref: 'y', + x: '2008-07-01', + ax: Dates.dateTime2ms('2004-07-01'), + y: 0, + ay: 50 + }] }; - Annotations.supplyLayoutDefaults(annotationIn, annotationOut); + Annotations.supplyLayoutDefaults(layoutIn, layoutOut); - expect(annotationIn.annotations[0].ax).toEqual(Dates.dateTime2ms('2004-07-01')); + expect(layoutOut.annotations[0].ax).toEqual('2004-07-01'); }); }); }); @@ -141,12 +151,16 @@ describe('annotations autosize', function() { // xaxis2 need a bit more tolerance to pass on CI // this most likely due to the different text bounding box values // on headfull vs headless browsers. - var PREC2 = 0.1; + // but also because it's a date axis that we've converted to ms + var PRECX2 = -10; + // yaxis2 needs a bit more now too... + var PRECY2 = 0.2; + var dateAx = fullLayout.xaxis2; expect(fullLayout.xaxis.range).toBeCloseToArray(x, PREC, '- xaxis'); expect(fullLayout.yaxis.range).toBeCloseToArray(y, PREC, '- yaxis'); - expect(fullLayout.xaxis2.range).toBeCloseToArray(x2, PREC2, 'xaxis2'); - expect(fullLayout.yaxis2.range).toBeCloseToArray(y2, PREC, 'yaxis2'); + expect(dateAx.range.map(dateAx.r2l)).toBeCloseToArray(x2.map(dateAx.r2l), PRECX2, 'xaxis2 ' + dateAx.range); + expect(fullLayout.yaxis2.range).toBeCloseToArray(y2, PRECY2, 'yaxis2'); expect(fullLayout.xaxis3.range).toBeCloseToArray(x3, PREC, 'xaxis3'); expect(fullLayout.yaxis3.range).toBeCloseToArray(y3, PREC, 'yaxis3'); } @@ -154,7 +168,7 @@ describe('annotations autosize', function() { Plotly.plot(gd, mock).then(function() { assertRanges( [0.97, 2.03], [0.97, 2.03], - [-0.32, 3.38], [0.42, 2.58], + ['2000-10-01 08:23:18.0583', '2001-06-05 19:20:23.301'], [-0.245, 4.245], [0.9, 2.1], [0.86, 2.14] ); @@ -167,7 +181,7 @@ describe('annotations autosize', function() { .then(function() { assertRanges( [1.44, 2.02], [0.97, 2.03], - [1.31, 2.41], [0.42, 2.58], + ['2001-01-18 15:06:04.0449', '2001-03-27 14:01:20.8989'], [-0.245, 4.245], [1.44, 2.1], [0.86, 2.14] ); @@ -180,7 +194,7 @@ describe('annotations autosize', function() { .then(function() { assertRanges( [1.44, 2.02], [0.99, 1.52], - [0.5, 2.5], [0.42, 2.58], + ['2001-01-31 23:59:59.999', '2001-02-01 00:00:00.001'], [-0.245, 4.245], [0.5, 2.5], [0.86, 2.14] ); @@ -196,7 +210,7 @@ describe('annotations autosize', function() { .then(function() { assertRanges( [0.97, 2.03], [0.97, 2.03], - [-0.32, 3.38], [0.42, 2.58], + ['2000-10-01 08:23:18.0583', '2001-06-05 19:20:23.301'], [-0.245, 4.245], [0.9, 2.1], [0.86, 2.14] ); }) diff --git a/test/jasmine/tests/axes_test.js b/test/jasmine/tests/axes_test.js index 619a22098cf..90433bd53af 100644 --- a/test/jasmine/tests/axes_test.js +++ b/test/jasmine/tests/axes_test.js @@ -676,6 +676,92 @@ describe('Test axes', function() { expect(axOut.dtick).toBe(0.00159); }); + it('should handle tick0 and dtick for date axes', function() { + var someMs = 123456789, + someMsDate = Lib.ms2DateTime(someMs), + oneDay = 24 * 3600 * 1000, + axIn = {tick0: someMs, dtick: String(3 * oneDay)}, + axOut = {}; + mockSupplyDefaults(axIn, axOut, 'date'); + expect(axOut.tick0).toBe(someMsDate); + expect(axOut.dtick).toBe(3 * oneDay); + + var someDate = '2011-12-15 13:45:56'; + axIn = {tick0: someDate, dtick: 'M15'}; + axOut = {}; + mockSupplyDefaults(axIn, axOut, 'date'); + expect(axOut.tick0).toBe(someDate); + expect(axOut.dtick).toBe('M15'); + + // dtick without tick0: get the right default + axIn = {dtick: 'M12'}; + axOut = {}; + mockSupplyDefaults(axIn, axOut, 'date'); + expect(axOut.tick0).toBe('2000-01-01'); + expect(axOut.dtick).toBe('M12'); + + // now some stuff that shouldn't work, should give defaults + [ + ['next thursday', -1], + ['123-45', 'L1'], + ['', 'M0.5'], + ['', 'M-1'], + ['', '2000-01-01'] + ].forEach(function(v) { + axIn = {tick0: v[0], dtick: v[1]}; + axOut = {}; + mockSupplyDefaults(axIn, axOut, 'date'); + expect(axOut.tick0).toBe('2000-01-01'); + expect(axOut.dtick).toBe(oneDay); + }); + }); + + it('should handle tick0 and dtick for log axes', function() { + var axIn = {tick0: '0.2', dtick: 0.3}, + axOut = {}; + mockSupplyDefaults(axIn, axOut, 'log'); + expect(axOut.tick0).toBe(0.2); + expect(axOut.dtick).toBe(0.3); + + ['D1', 'D2'].forEach(function(v) { + axIn = {tick0: -1, dtick: v}; + axOut = {}; + mockSupplyDefaults(axIn, axOut, 'log'); + // tick0 gets ignored for D + expect(axOut.tick0).toBe(0); + expect(axOut.dtick).toBe(v); + }); + + [ + [-1, 'L3'], + ['0.2', 'L0.3'], + [-1, 3], + ['0.1234', '0.69238473'] + ].forEach(function(v) { + axIn = {tick0: v[0], dtick: v[1]}; + axOut = {}; + mockSupplyDefaults(axIn, axOut, 'log'); + expect(axOut.tick0).toBe(Number(v[0])); + expect(axOut.dtick).toBe((+v[1]) ? Number(v[1]) : v[1]); + }); + + // now some stuff that should not work, should give defaults + [ + ['', -1], + ['D1', 'D3'], + ['', 'D0'], + ['2011-01-01', 'L0'], + ['', 'L-1'] + ].forEach(function(v) { + axIn = {tick0: v[0], dtick: v[1]}; + axOut = {}; + mockSupplyDefaults(axIn, axOut, 'log'); + expect(axOut.tick0).toBe(0); + expect(axOut.dtick).toBe(1); + }); + + }); + it('should set tickvals and ticktext iff tickmode=array', function() { var axIn = {tickmode: 'auto', tickvals: [1, 2, 3], ticktext: ['4', '5', '6']}, axOut = {}; @@ -1306,4 +1392,112 @@ describe('Test axes', function() { expect(ax._max).toEqual([{val: 6, pad: 15}]); }); }); + + describe('calcTicks', function() { + function mockCalc(ax) { + Axes.setConvert(ax); + ax.tickfont = {}; + ax._gd = {_fullLayout: {separators: '.,'}}; + return Axes.calcTicks(ax).map(function(v) { return v.text; }); + } + + it('provides a new date suffix whenever the suffix changes', function() { + var textOut = mockCalc({ + type: 'date', + tickmode: 'linear', + tick0: '2000-01-01', + dtick: 14 * 24 * 3600 * 1000, // 14 days + range: ['1999-12-01', '2000-02-15'] + }); + + var expectedText = [ + 'Dec 4
1999', + 'Dec 18', + 'Jan 1
2000', + 'Jan 15', + 'Jan 29', + 'Feb 12' + ]; + expect(textOut).toEqual(expectedText); + + textOut = mockCalc({ + type: 'date', + tickmode: 'linear', + tick0: '2000-01-01', + dtick: 12 * 3600 * 1000, // 12 hours + range: ['2000-01-03 11:00', '2000-01-06'] + }); + + expectedText = [ + '12:00
Jan 3, 2000', + '00:00
Jan 4, 2000', + '12:00', + '00:00
Jan 5, 2000', + '12:00', + '00:00
Jan 6, 2000' + ]; + expect(textOut).toEqual(expectedText); + + textOut = mockCalc({ + type: 'date', + tickmode: 'linear', + tick0: '2000-01-01', + dtick: 1000, // 1 sec + range: ['2000-02-03 23:59:57', '2000-02-04 00:00:02'] + }); + + expectedText = [ + '23:59:57
Feb 3, 2000', + '23:59:58', + '23:59:59', + '00:00:00
Feb 4, 2000', + '00:00:01', + '00:00:02' + ]; + expect(textOut).toEqual(expectedText); + }); + + it('should give dates extra precision if tick0 is weird', function() { + var textOut = mockCalc({ + type: 'date', + tickmode: 'linear', + tick0: '2000-01-01 00:05', + dtick: 14 * 24 * 3600 * 1000, // 14 days + range: ['1999-12-01', '2000-02-15'] + }); + + var expectedText = [ + '00:05
Dec 4, 1999', + '00:05
Dec 18, 1999', + '00:05
Jan 1, 2000', + '00:05
Jan 15, 2000', + '00:05
Jan 29, 2000', + '00:05
Feb 12, 2000' + ]; + expect(textOut).toEqual(expectedText); + }); + + it('should never give dates more than 100 microsecond precision', function() { + var textOut = mockCalc({ + type: 'date', + tickmode: 'linear', + tick0: '2000-01-01', + dtick: 1.1333, + range: ['2000-01-01', '2000-01-01 00:00:00.01'] + }); + + var expectedText = [ + '00:00:00
Jan 1, 2000', + '00:00:00.0011', + '00:00:00.0023', + '00:00:00.0034', + '00:00:00.0045', + '00:00:00.0057', + '00:00:00.0068', + '00:00:00.0079', + '00:00:00.0091' + ]; + expect(textOut).toEqual(expectedText); + }); + }); }); diff --git a/test/jasmine/tests/finance_test.js b/test/jasmine/tests/finance_test.js index 7a14de93544..992beda676b 100644 --- a/test/jasmine/tests/finance_test.js +++ b/test/jasmine/tests/finance_test.js @@ -352,10 +352,6 @@ describe('finance charts calc transforms:', function() { return gd.calcdata.map(calcDatatoTrace); } - function ms2DateTime(v) { - return typeof v === 'number' ? Lib.ms2DateTime(v) : null; - } - it('should fill when *x* is not present', function() { var trace0 = Lib.extendDeep({}, mock0, { type: 'ohlc', @@ -471,7 +467,7 @@ describe('finance charts calc transforms:', function() { expect(out.length).toEqual(4); - expect(out[0].x.map(ms2DateTime)).toEqual([ + expect(out[0].x).toEqual([ '2016-08-31 22:48', '2016-09-01', '2016-09-01', '2016-09-01', '2016-09-01', '2016-09-01 01:12', null, '2016-09-05 22:48', '2016-09-06', '2016-09-06', '2016-09-06', '2016-09-06', '2016-09-06 01:12', null, '2016-09-09 22:48', '2016-09-10', '2016-09-10', '2016-09-10', '2016-09-10', '2016-09-10 01:12', null @@ -481,7 +477,7 @@ describe('finance charts calc transforms:', function() { 33.05, 33.05, 33.25, 32.75, 33.1, 33.1, null, 33.5, 33.5, 34.62, 32.87, 33.7, 33.7, null ]); - expect(out[1].x.map(ms2DateTime)).toEqual([ + expect(out[1].x).toEqual([ '2016-09-01 22:48', '2016-09-02', '2016-09-02', '2016-09-02', '2016-09-02', '2016-09-02 01:12', null, '2016-09-02 22:48', '2016-09-03', '2016-09-03', '2016-09-03', '2016-09-03', '2016-09-03 01:12', null, '2016-09-04 22:48', '2016-09-05', '2016-09-05', '2016-09-05', '2016-09-05', '2016-09-05 01:12', null, @@ -522,15 +518,15 @@ describe('finance charts calc transforms:', function() { var out = _calc([trace0]); expect(out[0].name).toEqual('trace 0 - increasing'); - expect(out[0].x.map(ms2DateTime)).toEqual([ - '2016-08-31 22:48', '2016-09-01', '2016-09-01', '2016-09-01', '2016-09-01', '2016-09-01 01:12', null, + expect(out[0].x).toEqual([ + '2016-08-31 22:48', '2016-09-01', '2016-09-01', '2016-09-01', '2016-09-01', '2016-09-01 01:12', null ]); expect(out[0].y).toEqual([ 33.01, 33.01, 34.2, 31.7, 34.1, 34.1, null, ]); expect(out[1].name).toEqual('trace 0 - decreasing'); - expect(out[1].x.map(ms2DateTime)).toEqual([ + expect(out[1].x).toEqual([ '2016-09-01 22:48', '2016-09-02', '2016-09-02', '2016-09-02', '2016-09-02', '2016-09-02 01:12', null, '2016-09-02 22:48', '2016-09-03', '2016-09-03', '2016-09-03', '2016-09-03', '2016-09-03 01:12', null ]); @@ -540,7 +536,7 @@ describe('finance charts calc transforms:', function() { ]); expect(out[2].name).toEqual('trace 0 - increasing'); - expect(out[2].x.map(ms2DateTime)).toEqual([ + expect(out[2].x).toEqual([ '2016-09-03 22:48', '2016-09-04', '2016-09-04', '2016-09-04', '2016-09-04', '2016-09-04 01:12', null ]); expect(out[2].y).toEqual([ @@ -548,7 +544,7 @@ describe('finance charts calc transforms:', function() { ]); expect(out[3].name).toEqual('trace 0 - decreasing'); - expect(out[3].x.map(ms2DateTime)).toEqual([]); + expect(out[3].x).toEqual([]); expect(out[3].y).toEqual([]); }); @@ -612,32 +608,32 @@ describe('finance charts calc transforms:', function() { var out = _calc([trace0, trace1]); - expect(out[0].x.map(ms2DateTime)).toEqual([ - '2016-08-31 12', '2016-09-01', '2016-09-01', '2016-09-01', '2016-09-01', '2016-09-01 12', null, - '2016-09-03 12', '2016-09-04', '2016-09-04', '2016-09-04', '2016-09-04', '2016-09-04 12', null, - '2016-09-05 12', '2016-09-06', '2016-09-06', '2016-09-06', '2016-09-06', '2016-09-06 12', null, - '2016-09-09 12', '2016-09-10', '2016-09-10', '2016-09-10', '2016-09-10', '2016-09-10 12', null + expect(out[0].x).toEqual([ + '2016-08-31 12:00', '2016-09-01', '2016-09-01', '2016-09-01', '2016-09-01', '2016-09-01 12:00', null, + '2016-09-03 12:00', '2016-09-04', '2016-09-04', '2016-09-04', '2016-09-04', '2016-09-04 12:00', null, + '2016-09-05 12:00', '2016-09-06', '2016-09-06', '2016-09-06', '2016-09-06', '2016-09-06 12:00', null, + '2016-09-09 12:00', '2016-09-10', '2016-09-10', '2016-09-10', '2016-09-10', '2016-09-10 12:00', null ]); - expect(out[1].x.map(ms2DateTime)).toEqual([ - '2016-09-01 12', '2016-09-02', '2016-09-02', '2016-09-02', '2016-09-02', '2016-09-02 12', null, - '2016-09-02 12', '2016-09-03', '2016-09-03', '2016-09-03', '2016-09-03', '2016-09-03 12', null, - '2016-09-04 12', '2016-09-05', '2016-09-05', '2016-09-05', '2016-09-05', '2016-09-05 12', null, - '2016-09-06 12', '2016-09-07', '2016-09-07', '2016-09-07', '2016-09-07', '2016-09-07 12', null + expect(out[1].x).toEqual([ + '2016-09-01 12:00', '2016-09-02', '2016-09-02', '2016-09-02', '2016-09-02', '2016-09-02 12:00', null, + '2016-09-02 12:00', '2016-09-03', '2016-09-03', '2016-09-03', '2016-09-03', '2016-09-03 12:00', null, + '2016-09-04 12:00', '2016-09-05', '2016-09-05', '2016-09-05', '2016-09-05', '2016-09-05 12:00', null, + '2016-09-06 12:00', '2016-09-07', '2016-09-07', '2016-09-07', '2016-09-07', '2016-09-07 12:00', null ]); - expect(out[2].x.map(ms2DateTime)).toEqual([ - '2016-08-31 22', '2016-09-01 10', '2016-09-01 10', '2016-09-01 10', '2016-09-01 10', '2016-09-01 22', null, - '2016-09-03 22', '2016-09-04 10', '2016-09-04 10', '2016-09-04 10', '2016-09-04 10', '2016-09-04 22', null, - '2016-09-05 22', '2016-09-06 10', '2016-09-06 10', '2016-09-06 10', '2016-09-06 10', '2016-09-06 22', null, - '2016-09-09 22', '2016-09-10 10', '2016-09-10 10', '2016-09-10 10', '2016-09-10 10', '2016-09-10 22', null + expect(out[2].x).toEqual([ + '2016-08-31 22:00', '2016-09-01 10:00', '2016-09-01 10:00', '2016-09-01 10:00', '2016-09-01 10:00', '2016-09-01 22:00', null, + '2016-09-03 22:00', '2016-09-04 10:00', '2016-09-04 10:00', '2016-09-04 10:00', '2016-09-04 10:00', '2016-09-04 22:00', null, + '2016-09-05 22:00', '2016-09-06 10:00', '2016-09-06 10:00', '2016-09-06 10:00', '2016-09-06 10:00', '2016-09-06 22:00', null, + '2016-09-09 22:00', '2016-09-10 10:00', '2016-09-10 10:00', '2016-09-10 10:00', '2016-09-10 10:00', '2016-09-10 22:00', null ]); - expect(out[3].x.map(ms2DateTime)).toEqual([ - '2016-09-01 22', '2016-09-02 10', '2016-09-02 10', '2016-09-02 10', '2016-09-02 10', '2016-09-02 22', null, - '2016-09-02 22', '2016-09-03 10', '2016-09-03 10', '2016-09-03 10', '2016-09-03 10', '2016-09-03 22', null, - '2016-09-04 22', '2016-09-05 10', '2016-09-05 10', '2016-09-05 10', '2016-09-05 10', '2016-09-05 22', null, - '2016-09-06 22', '2016-09-07 10', '2016-09-07 10', '2016-09-07 10', '2016-09-07 10', '2016-09-07 22', null + expect(out[3].x).toEqual([ + '2016-09-01 22:00', '2016-09-02 10:00', '2016-09-02 10:00', '2016-09-02 10:00', '2016-09-02 10:00', '2016-09-02 22:00', null, + '2016-09-02 22:00', '2016-09-03 10:00', '2016-09-03 10:00', '2016-09-03 10:00', '2016-09-03 10:00', '2016-09-03 22:00', null, + '2016-09-04 22:00', '2016-09-05 10:00', '2016-09-05 10:00', '2016-09-05 10:00', '2016-09-05 10:00', '2016-09-05 22:00', null, + '2016-09-06 22:00', '2016-09-07 10:00', '2016-09-07 10:00', '2016-09-07 10:00', '2016-09-07 10:00', '2016-09-07 22:00', null ]); }); @@ -656,10 +652,10 @@ describe('finance charts calc transforms:', function() { var out = _calc([trace0, trace1]); - var x0 = out[0].x; + var x0 = out[0].x.map(Lib.dateTime2ms); expect(x0[x0.length - 2] - x0[0]).toEqual(1); - var x2 = out[2].x; + var x2 = out[2].x.map(Lib.dateTime2ms); expect(x2[x2.length - 2] - x2[0]).toEqual(1); expect(out[1].x).toEqual([]); diff --git a/test/jasmine/tests/lib_date_test.js b/test/jasmine/tests/lib_date_test.js new file mode 100644 index 00000000000..f4771c539a4 --- /dev/null +++ b/test/jasmine/tests/lib_date_test.js @@ -0,0 +1,260 @@ +var isNumeric = require('fast-isnumeric'); +var Lib = require('@src/lib'); + +describe('dates', function() { + 'use strict'; + + var d1c = new Date(2000, 0, 1, 1, 0, 0, 600); + // first-century years must be set separately as Date constructor maps 2-digit years + // to near the present, but we accept them as part of 4-digit years + d1c.setFullYear(13); + + var thisYear = new Date().getFullYear(), + thisYear_2 = thisYear % 100, + nowMinus70 = thisYear - 70, + nowMinus70_2 = nowMinus70 % 100, + nowPlus29 = thisYear + 29, + nowPlus29_2 = nowPlus29 % 100; + + describe('dateTime2ms', function() { + it('should accept valid date strings', function() { + + [ + ['2016', new Date(2016, 0, 1)], + ['2016-05', new Date(2016, 4, 1)], + // leap year, and whitespace + ['\r\n\t 2016-02-29\r\n\t ', new Date(2016, 1, 29)], + ['9814-08-23', new Date(9814, 7, 23)], + ['1564-03-14 12', new Date(1564, 2, 14, 12)], + ['0122-04-08 08:22', new Date(122, 3, 8, 8, 22)], + ['-0098-11-19 23:59:59', new Date(-98, 10, 19, 23, 59, 59)], + ['-9730-12-01 12:34:56.789', new Date(-9730, 11, 1, 12, 34, 56, 789)], + // first century, also allow month, day, and hour to be 1-digit, and not all + // three digits of milliseconds + ['0013-1-1 1:00:00.6', d1c], + // we support more than 4 digits too, though Date objects don't. More than that + // and we hit the precision limit of js numbers unless we're close to the epoch. + // It won't break though. + ['0013-1-1 1:00:00.6001', +d1c + 0.1], + + // 2-digit years get mapped to now-70 -> now+29 + [thisYear_2 + '-05', new Date(thisYear, 4, 1)], + [nowMinus70_2 + '-10-18', new Date(nowMinus70, 9, 18)], + [nowPlus29_2 + '-02-12 14:29:32', new Date(nowPlus29, 1, 12, 14, 29, 32)] + ].forEach(function(v) { + expect(Lib.dateTime2ms(v[0])).toBe(+v[1], v[0]); + }); + }); + + it('should accept 4-digit and 2-digit numbers', function() { + // not sure if we really *want* this behavior, but it's what we have. + // especially since the number 0 is *not* allowed it seems pretty unlikely + // to cause problems for people using milliseconds as dates, since the only + // values to get mistaken are between 1 and 10 seconds before and after + // the epoch, and between 10 and 99 milliseconds after the epoch + // (note that millisecond numbers are not handled by dateTime2ms directly, + // but in ax.d2c if dateTime2ms fails.) + [ + 1000, 9999, -1000, -9999 + ].forEach(function(v) { + expect(Lib.dateTime2ms(v)).toBe(+(new Date(v, 0, 1)), v); + }); + + [ + [10, 2010], + [nowPlus29_2, nowPlus29], + [nowMinus70_2, nowMinus70], + [99, 1999] + ].forEach(function(v) { + expect(Lib.dateTime2ms(v[0])).toBe(+(new Date(v[1], 0, 1)), v[0]); + }); + }); + + it('should accept Date objects within year +/-9999', function() { + [ + new Date(), + new Date(-9999, 0, 1), + new Date(9999, 11, 31, 23, 59, 59, 999), + new Date(-1, 0, 1), + new Date(323, 11, 30), + new Date(-456, 1, 2), + d1c, + new Date(2015, 8, 7, 23, 34, 45, 567) + ].forEach(function(v) { + expect(Lib.dateTime2ms(v)).toBe(+v); + }); + }); + + it('should not accept Date objects beyond our limits', function() { + [ + new Date(10000, 0, 1), + new Date(-10000, 11, 31, 23, 59, 59, 999) + ].forEach(function(v) { + expect(Lib.dateTime2ms(v)).toBeUndefined(v); + }); + }); + + it('should not accept invalid strings or other objects', function() { + [ + '', 0, 1, 9, -1, -10, -99, 100, 999, -100, -999, 10000, -10000, + 1.2, -1.2, 2015.1, -1023.4, NaN, null, undefined, Infinity, -Infinity, + {}, {1: '2014-01-01'}, [], [2016], ['2015-11-23'], + '123-01-01', '-756-01-01', // 3-digit year + '10000-01-01', '-10000-01-01', // 5-digit year + '2015-00-01', '2015-13-01', '2015-001-01', // bad month + '2015-01-00', '2015-01-32', '2015-02-29', '2015-04-31', '2015-01-001', // bad day (incl non-leap year) + '2015-01-01 24:00', '2015-01-01 -1:00', '2015-01-01 001:00', // bad hour + '2015-01-01 12:60', '2015-01-01 12:-1', '2015-01-01 12:001', '2015-01-01 12:1', // bad minute + '2015-01-01 12:00:60', '2015-01-01 12:00:-1', '2015-01-01 12:00:001', '2015-01-01 12:00:1' // bad second + ].forEach(function(v) { + expect(Lib.dateTime2ms(v)).toBeUndefined(v); + }); + }); + }); + + describe('ms2DateTime', function() { + it('should report the minimum fields with nonzero values, except minutes', function() { + [ + '2016-01-01', // we'll never report less than this bcs month and day are never zero + '2016-01-01 01:00', // we won't report hours without minutes + '2016-01-01 01:01', + '2016-01-01 01:01:01', + '2016-01-01 01:01:01.1', + '2016-01-01 01:01:01.01', + '2016-01-01 01:01:01.001', + '2016-01-01 01:01:01.0001' + ].forEach(function(v) { + expect(Lib.ms2DateTime(Lib.dateTime2ms(v))).toBe(v); + }); + }); + + it('should accept Date objects within year +/-9999', function() { + [ + '-9999-01-01', + '-9999-01-01 00:00:00.0001', + '9999-12-31 23:59:59.9999', + '0123-01-01', + '0042-01-01', + '-0016-01-01', + '-0016-01-01 12:34:56.7891', + '-0456-07-23 16:22' + ].forEach(function(v) { + expect(Lib.ms2DateTime(Lib.dateTime2ms(v))).toBe(v); + }); + }); + + it('should not accept Date objects beyond our limits or other objects', function() { + [ + +(new Date(10000, 0, 1)), + +(new Date(-10000, 11, 31, 23, 59, 59, 999)), + '', + '2016-01-01', + '0', + [], [0], {}, {1: 2} + ].forEach(function(v) { + expect(Lib.ms2DateTime(v)).toBeUndefined(v); + }); + }); + + it('should drop the right pieces if rounding is specified', function() { + [ + ['2016-01-01 00:00:00.0001', 0, '2016-01-01 00:00:00.0001'], + ['2016-01-01 00:00:00.0001', 299999, '2016-01-01 00:00:00.0001'], + ['2016-01-01 00:00:00.0001', 300000, '2016-01-01'], + ['2016-01-01 00:00:00.0001', 7776000000, '2016-01-01'], + ['2016-01-01 12:34:56.7891', 0, '2016-01-01 12:34:56.7891'], + ['2016-01-01 12:34:56.7891', 299999, '2016-01-01 12:34:56.7891'], + ['2016-01-01 12:34:56.7891', 300000, '2016-01-01 12:34:56'], + ['2016-01-01 12:34:56.7891', 10799999, '2016-01-01 12:34:56'], + ['2016-01-01 12:34:56.7891', 10800000, '2016-01-01 12:34'], + ['2016-01-01 12:34:56.7891', 7775999999, '2016-01-01 12:34'], + ['2016-01-01 12:34:56.7891', 7776000000, '2016-01-01'], + ['2016-01-01 12:34:56.7891', 1e300, '2016-01-01'] + ].forEach(function(v) { + expect(Lib.ms2DateTime(Lib.dateTime2ms(v[0]), v[1])).toBe(v[2], v); + }); + }); + }); + + describe('cleanDate', function() { + it('should convert any number or js Date within range to a date string', function() { + [ + new Date(0), + new Date(2000), + new Date(2000, 0, 1), + new Date(), + new Date(-9999, 0, 1), + new Date(9999, 11, 31, 23, 59, 59, 999) + ].forEach(function(v) { + expect(typeof Lib.ms2DateTime(+v)).toBe('string'); + expect(Lib.cleanDate(v)).toBe(Lib.ms2DateTime(+v)); + expect(Lib.cleanDate(+v)).toBe(Lib.ms2DateTime(+v)); + expect(Lib.cleanDate(v, '2000-01-01')).toBe(Lib.ms2DateTime(+v)); + }); + }); + + it('should fail numbers & js Dates out of range, and other bad objects', function() { + [ + new Date(-20000, 0, 1), + new Date(20000, 0, 1), + new Date('fail'), + undefined, null, NaN, + [], {}, [0], {1: 2}, '', + '2001-02-29' // not a leap year + ].forEach(function(v) { + expect(Lib.cleanDate(v)).toBeUndefined(); + if(!isNumeric(+v)) expect(Lib.cleanDate(+v)).toBeUndefined(); + expect(Lib.cleanDate(v, '2000-01-01')).toBe('2000-01-01'); + }); + }); + + it('should not alter valid date strings, even to truncate them', function() { + [ + '2000', + '2000-01', + '2000-01-01', + '2000-01-01 00', + '2000-01-01 00:00', + '2000-01-01 00:00:00', + '2000-01-01 00:00:00.0', + '2000-01-01 00:00:00.00', + '2000-01-01 00:00:00.000', + '2000-01-01 00:00:00.0000', + '9999-12-31 23:59:59.9999', + '-9999-01-01 00:00:00.0000', + '99-01-01', + '00-01-01' + ].forEach(function(v) { + expect(Lib.cleanDate(v)).toBe(v); + }); + }); + }); + + describe('isJSDate', function() { + it('should return true for any Date object but not the equivalent numbers', function() { + [ + new Date(), + new Date(0), + new Date(-9900, 1, 2, 3, 4, 5, 6), + new Date(9900, 1, 2, 3, 4, 5, 6), + new Date(-20000, 0, 1), new Date(20000, 0, 1), // outside our range, still true + new Date('fail') // `Invalid Date` is still a Date + ].forEach(function(v) { + expect(Lib.isJSDate(v)).toBe(true); + expect(Lib.isJSDate(+v)).toBe(false); + }); + }); + + it('should return false for anything thats not explicitly a JS Date', function() { + [ + 0, NaN, null, undefined, '', {}, [], [0], [2016, 0, 1], + '2016-01-01', '2016-01-01 12:34:56', '2016-01-01 12:34:56.789', + 'Thu Oct 20 2016 15:35:14 GMT-0400 (EDT)', + // getting really close to a hack of our test... we look for getTime to be a function + {getTime: 4} + ].forEach(function(v) { + expect(Lib.isJSDate(v)).toBe(false); + }); + }); + }); +}); diff --git a/test/jasmine/tests/lib_test.js b/test/jasmine/tests/lib_test.js index 1cba4bd7745..917d98abedc 100644 --- a/test/jasmine/tests/lib_test.js +++ b/test/jasmine/tests/lib_test.js @@ -12,39 +12,6 @@ var customMatchers = require('../assets/custom_matchers'); describe('Test lib.js:', function() { 'use strict'; - describe('parseDate() should', function() { - it('return false on bad (number) input:', function() { - expect(Lib.parseDate(0)).toBe(false); - }); - it('return false on bad (string) input:', function() { - expect(Lib.parseDate('toto')).toBe(false); - }); - it('work with yyyy-mm-dd string input:', function() { - var input = '2014-12-01', - res = Lib.parseDate(input), - res0 = new Date(2014, 11, 1); - expect(res.getTime()).toEqual(res0.getTime()); - }); - it('work with mm/dd/yyyy string input:', function() { - var input = '12/01/2014', - res = Lib.parseDate(input), - res0 = new Date(2014, 11, 1); - expect(res.getTime()).toEqual(res0.getTime()); - }); - it('work with yyyy-mm-dd HH:MM:SS.sss string input:', function() { - var input = '2014-12-01 09:50:05.124', - res = Lib.parseDate(input), - res0 = new Date(2014, 11, 1, 9, 50, 5, 124); - expect(res.getTime()).toEqual(res0.getTime()); - }); - it('work with mm/dd/yyyy HH:MM:SS string input:', function() { - var input = '2014-12-01 09:50:05', - res = Lib.parseDate(input), - res0 = new Date(2014, 11, 1, 9, 50, 5); - expect(res.getTime()).toEqual(res0.getTime()); - }); - }); - describe('interp() should', function() { it('return 1.75 as Q1 of [1, 2, 3, 4, 5]:', function() { var input = [1, 2, 3, 4, 5], @@ -1521,6 +1488,42 @@ describe('Test lib.js:', function() { }); }); + describe('cleanNumber', function() { + it('should return finite numbers untouched', function() { + [ + 0, 1, 2, 1234.567, + -1, -100, -999.999, + Number.MAX_VALUE, Number.MIN_VALUE, Number.EPSILON, + -Number.MAX_VALUE, -Number.MIN_VALUE, -Number.EPSILON + ].forEach(function(v) { + expect(Lib.cleanNumber(v)).toBe(v); + }); + }); + + it('should accept number strings with arbitrary cruft on the outside', function() { + [ + ['0', 0], + ['1', 1], + ['1.23', 1.23], + ['-100.001', -100.001], + [' $4.325 #%\t', 4.325], + [' " #1" ', 1], + [' \'\n \r -9.2e7 \t\' ', -9.2e7] + ].forEach(function(v) { + expect(Lib.cleanNumber(v[0])).toBe(v[1], v[0]); + }); + }); + + it('should not accept other objects or cruft in the middle', function() { + [ + NaN, Infinity, -Infinity, null, undefined, new Date(), '', + ' ', '\t', '2 2', '2%2', '2$2', {1: 2}, [1], ['1'], {}, [] + ].forEach(function(v) { + expect(Lib.cleanNumber(v)).toBeUndefined(v); + }); + }); + }); + describe('isPlotDiv', function() { it('should work on plain objects', function() { expect(Lib.isPlotDiv({})).toBe(false); diff --git a/test/jasmine/tests/mapbox_test.js b/test/jasmine/tests/mapbox_test.js index 6d0f6009987..34eafbc05dd 100644 --- a/test/jasmine/tests/mapbox_test.js +++ b/test/jasmine/tests/mapbox_test.js @@ -449,7 +449,7 @@ describe('mapbox plots', function() { var divStyle = mapInfo.div.style; ['left', 'top', 'width', 'height'].forEach(function(p, i) { - expect(parseFloat(divStyle[p])).toBeWithin(dims[i], 5); + expect(parseFloat(divStyle[p])).toBeWithin(dims[i], 8); }); } diff --git a/test/jasmine/tests/range_selector_test.js b/test/jasmine/tests/range_selector_test.js index fd8a8072549..cae2373e79b 100644 --- a/test/jasmine/tests/range_selector_test.js +++ b/test/jasmine/tests/range_selector_test.js @@ -180,17 +180,23 @@ describe('range selector defaults:', function() { describe('range selector getUpdateObject:', function() { 'use strict'; - var axisLayout = { - _name: 'xaxis', - range: [ - (new Date(1948, 0, 1)).getTime(), - (new Date(2015, 10, 30)).getTime() - ] - }; - function assertRanges(update, range0, range1) { - expect(update['xaxis.range[0]']).toEqual(range0.getTime()); - expect(update['xaxis.range[1]']).toEqual(range1.getTime()); + expect(update['xaxis.range[0]']).toEqual(range0); + expect(update['xaxis.range[1]']).toEqual(range1); + } + + // buttonLayout: {step, stepmode, count} + // range0out: expected resulting range[0] (input is always '1948-01-01') + // range1: input range[1], expected to also be the output + function assertUpdateCase(buttonLayout, range0out, range1) { + var axisLayout = { + _name: 'xaxis', + range: ['1948-01-01', range1] + }; + + var update = getUpdateObject(axisLayout, buttonLayout); + + assertRanges(update, range0out, range1); } it('should return update object (1 month backward case)', function() { @@ -200,9 +206,8 @@ describe('range selector getUpdateObject:', function() { count: 1 }; - var update = getUpdateObject(axisLayout, buttonLayout); - - assertRanges(update, new Date(2015, 9, 30), new Date(2015, 10, 30)); + assertUpdateCase(buttonLayout, '2015-10-30', '2015-11-30'); + assertUpdateCase(buttonLayout, '2015-10-30 12:34:56', '2015-11-30 12:34:56'); }); it('should return update object (3 months backward case)', function() { @@ -212,9 +217,8 @@ describe('range selector getUpdateObject:', function() { count: 3 }; - var update = getUpdateObject(axisLayout, buttonLayout); - - assertRanges(update, new Date(2015, 7, 30), new Date(2015, 10, 30)); + assertUpdateCase(buttonLayout, '2015-08-30', '2015-11-30'); + assertUpdateCase(buttonLayout, '2015-08-30 12:34:56', '2015-11-30 12:34:56'); }); it('should return update object (6 months backward case)', function() { @@ -224,9 +228,8 @@ describe('range selector getUpdateObject:', function() { count: 6 }; - var update = getUpdateObject(axisLayout, buttonLayout); - - assertRanges(update, new Date(2015, 4, 30), new Date(2015, 10, 30)); + assertUpdateCase(buttonLayout, '2015-05-30', '2015-11-30'); + assertUpdateCase(buttonLayout, '2015-05-30 12:34:56', '2015-11-30 12:34:56'); }); it('should return update object (5 months to-date case)', function() { @@ -236,9 +239,9 @@ describe('range selector getUpdateObject:', function() { count: 5 }; - var update = getUpdateObject(axisLayout, buttonLayout); - - assertRanges(update, new Date(2015, 6, 1), new Date(2015, 10, 30)); + assertUpdateCase(buttonLayout, '2015-07-01', '2015-11-30'); + assertUpdateCase(buttonLayout, '2015-07-01', '2015-12-01'); + assertUpdateCase(buttonLayout, '2015-08-01', '2015-12-01 00:00:01'); }); it('should return update object (1 year to-date case)', function() { @@ -248,9 +251,9 @@ describe('range selector getUpdateObject:', function() { count: 1 }; - var update = getUpdateObject(axisLayout, buttonLayout); - - assertRanges(update, new Date(2015, 0, 1), new Date(2015, 10, 30)); + assertUpdateCase(buttonLayout, '2015-01-01', '2015-11-30'); + assertUpdateCase(buttonLayout, '2015-01-01', '2016-01-01'); + assertUpdateCase(buttonLayout, '2016-01-01', '2016-01-01 00:00:01'); }); it('should return update object (10 year to-date case)', function() { @@ -260,9 +263,9 @@ describe('range selector getUpdateObject:', function() { count: 10 }; - var update = getUpdateObject(axisLayout, buttonLayout); - - assertRanges(update, new Date(2006, 0, 1), new Date(2015, 10, 30)); + assertUpdateCase(buttonLayout, '2006-01-01', '2015-11-30'); + assertUpdateCase(buttonLayout, '2006-01-01', '2016-01-01'); + assertUpdateCase(buttonLayout, '2007-01-01', '2016-01-01 00:00:01'); }); it('should return update object (1 year backward case)', function() { @@ -272,19 +275,23 @@ describe('range selector getUpdateObject:', function() { count: 1 }; - var update = getUpdateObject(axisLayout, buttonLayout); - - assertRanges(update, new Date(2014, 10, 30), new Date(2015, 10, 30)); + assertUpdateCase(buttonLayout, '2014-11-30', '2015-11-30'); + assertUpdateCase(buttonLayout, '2014-11-30 12:34:56', '2015-11-30 12:34:56'); }); it('should return update object (reset case)', function() { + var axisLayout = { + _name: 'xaxis', + range: ['1948-01-01', '2015-11-30'] + }; + var buttonLayout = { step: 'all' }; var update = getUpdateObject(axisLayout, buttonLayout); - expect(update).toEqual({ 'xaxis.autorange': true }); + expect(update).toEqual({'xaxis.autorange': true}); }); it('should return update object (10 day backward case)', function() { @@ -294,9 +301,8 @@ describe('range selector getUpdateObject:', function() { count: 10 }; - var update = getUpdateObject(axisLayout, buttonLayout); - - assertRanges(update, new Date(2015, 10, 20), new Date(2015, 10, 30)); + assertUpdateCase(buttonLayout, '2015-11-20', '2015-11-30'); + assertUpdateCase(buttonLayout, '2015-11-20 12:34:56', '2015-11-30 12:34:56'); }); it('should return update object (5 hour backward case)', function() { @@ -306,9 +312,8 @@ describe('range selector getUpdateObject:', function() { count: 5 }; - var update = getUpdateObject(axisLayout, buttonLayout); - - assertRanges(update, new Date(2015, 10, 29, 19), new Date(2015, 10, 30)); + assertUpdateCase(buttonLayout, '2015-11-29 19:00', '2015-11-30'); + assertUpdateCase(buttonLayout, '2015-11-30 07:34:56', '2015-11-30 12:34:56'); }); it('should return update object (15 minute backward case)', function() { @@ -318,9 +323,8 @@ describe('range selector getUpdateObject:', function() { count: 15 }; - var update = getUpdateObject(axisLayout, buttonLayout); - - assertRanges(update, new Date(2015, 10, 29, 23, 45), new Date(2015, 10, 30)); + assertUpdateCase(buttonLayout, '2015-11-29 23:45', '2015-11-30'); + assertUpdateCase(buttonLayout, '2015-11-30 12:19:56', '2015-11-30 12:34:56'); }); it('should return update object (10 second backward case)', function() { @@ -330,9 +334,8 @@ describe('range selector getUpdateObject:', function() { count: 10 }; - var update = getUpdateObject(axisLayout, buttonLayout); - - assertRanges(update, new Date(2015, 10, 29, 23, 59, 50), new Date(2015, 10, 30)); + assertUpdateCase(buttonLayout, '2015-11-29 23:59:50', '2015-11-30'); + assertUpdateCase(buttonLayout, '2015-11-30 12:34:46', '2015-11-30 12:34:56'); }); it('should return update object (12 hour to-date case)', function() { @@ -342,25 +345,21 @@ describe('range selector getUpdateObject:', function() { count: 12 }; - axisLayout.range[1] = new Date(2015, 10, 30, 12).getTime(); - - var update = getUpdateObject(axisLayout, buttonLayout); - - assertRanges(update, new Date(2015, 10, 30, 1), new Date(2015, 10, 30, 12)); + assertUpdateCase(buttonLayout, '2015-11-30', '2015-11-30 12'); + assertUpdateCase(buttonLayout, '2015-11-30 01:00', '2015-11-30 12:00:01'); + assertUpdateCase(buttonLayout, '2015-11-30 01:00', '2015-11-30 13'); }); - it('should return update object (15 minute backward case)', function() { + it('should return update object (20 minute to-date case)', function() { var buttonLayout = { step: 'minute', stepmode: 'todate', count: 20 }; - axisLayout.range[1] = new Date(2015, 10, 30, 12, 20).getTime(); - - var update = getUpdateObject(axisLayout, buttonLayout); - - assertRanges(update, new Date(2015, 10, 30, 12, 1), new Date(2015, 10, 30, 12, 20)); + assertUpdateCase(buttonLayout, '2015-11-30 12:00', '2015-11-30 12:20'); + assertUpdateCase(buttonLayout, '2015-11-30 12:01', '2015-11-30 12:20:01'); + assertUpdateCase(buttonLayout, '2015-11-30 12:01', '2015-11-30 12:21'); }); it('should return update object (2 second to-date case)', function() { @@ -370,20 +369,15 @@ describe('range selector getUpdateObject:', function() { count: 2 }; - axisLayout.range[1] = new Date(2015, 10, 30, 12, 20, 2).getTime(); - - var update = getUpdateObject(axisLayout, buttonLayout); - - assertRanges(update, new Date(2015, 10, 30, 12, 20, 1), new Date(2015, 10, 30, 12, 20, 2)); + assertUpdateCase(buttonLayout, '2015-11-30 12:20', '2015-11-30 12:20:02'); + assertUpdateCase(buttonLayout, '2015-11-30 12:20:01', '2015-11-30 12:20:02.001'); + assertUpdateCase(buttonLayout, '2015-11-30 12:20:01', '2015-11-30 12:20:03'); }); it('should return update object with correct axis names', function() { var axisLayout = { _name: 'xaxis5', - range: [ - (new Date(1948, 0, 1)).getTime(), - (new Date(2015, 10, 30)).getTime() - ] + range: ['1948-01-01', '2015-11-30'] }; var buttonLayout = { @@ -395,8 +389,8 @@ describe('range selector getUpdateObject:', function() { var update = getUpdateObject(axisLayout, buttonLayout); expect(update).toEqual({ - 'xaxis5.range[0]': new Date(2015, 9, 30).getTime(), - 'xaxis5.range[1]': new Date(2015, 10, 30).getTime() + 'xaxis5.range[0]': '2015-10-30', + 'xaxis5.range[1]': '2015-11-30' }); }); @@ -422,9 +416,9 @@ describe('range selector interactions:', function() { expect(d3.selectAll(query).size()).toEqual(cnt); } - function checkActiveButton(activeIndex) { + function checkActiveButton(activeIndex, msg) { d3.selectAll('.button').each(function(d, i) { - expect(d.isActive).toBe(activeIndex === i); + expect(d.isActive).toBe(activeIndex === i, msg + ': button #' + i); }); } @@ -524,23 +518,23 @@ describe('range selector interactions:', function() { var buttons = d3.selectAll('.button').select('rect'); // 'all' should be active at first - checkActiveButton(buttons.size() - 1); + checkActiveButton(buttons.size() - 1, 'initial'); var update = { - 'xaxis.range[0]': (new Date(2015, 9, 30)).getTime(), - 'xaxis.range[1]': (new Date(2015, 10, 30)).getTime() + 'xaxis.range[0]': '2015-10-30', + 'xaxis.range[1]': '2015-11-30' }; Plotly.relayout(gd, update).then(function() { // '1m' should be active after the relayout - checkActiveButton(0); + checkActiveButton(0, '1m'); return Plotly.relayout(gd, 'xaxis.autorange', true); }).then(function() { // 'all' should be after an autoscale - checkActiveButton(buttons.size() - 1); + checkActiveButton(buttons.size() - 1, 'back to all'); done(); }); diff --git a/test/jasmine/tests/range_slider_test.js b/test/jasmine/tests/range_slider_test.js index 60b7ef88444..464c5a49776 100644 --- a/test/jasmine/tests/range_slider_test.js +++ b/test/jasmine/tests/range_slider_test.js @@ -1,5 +1,6 @@ var Plotly = require('@lib/index'); var Lib = require('@src/lib'); +var setConvert = require('@src/plots/cartesian/set_convert'); var RangeSlider = require('@src/components/rangeslider'); var constants = require('@src/components/rangeslider/constants'); @@ -473,7 +474,7 @@ describe('the range slider', function() { it('should expand the rangeslider range to axis range', function() { var layoutIn = { xaxis: { rangeslider: { range: [5, 6] } }, yaxis: {}}, - layoutOut = { xaxis: { range: [1, 10]}, yaxis: {}}, + layoutOut = { xaxis: { range: [1, 10], type: 'linear'}, yaxis: {}}, axName = 'xaxis', counterAxes = ['yaxis'], expected = { @@ -491,10 +492,14 @@ describe('the range slider', function() { }, yaxis: { fixedrange: true } }; + setConvert(layoutOut.xaxis); RangeSlider.handleDefaults(layoutIn, layoutOut, axName, counterAxes); - expect(layoutOut).toEqual(expected); + // don't compare the whole layout, because we had to run setConvert which + // attaches all sorts of other stuff to xaxis + expect(layoutOut.xaxis.rangeslider).toEqual(expected.xaxis.rangeslider); + expect(layoutOut.yaxis).toEqual(expected.yaxis); }); it('should set _needsExpand when an axis range is set', function() { diff --git a/test/jasmine/tests/shapes_test.js b/test/jasmine/tests/shapes_test.js index c00f104a90d..f592201600a 100644 --- a/test/jasmine/tests/shapes_test.js +++ b/test/jasmine/tests/shapes_test.js @@ -1,10 +1,12 @@ var helpers = require('@src/components/shapes/helpers'); var constants = require('@src/components/shapes/constants'); +var handleShapeDefaults = require('@src/components/shapes/shape_defaults'); var Plotly = require('@lib/index'); var PlotlyInternal = require('@src/plotly'); var Lib = require('@src/lib'); var Axes = PlotlyInternal.Axes; +var Plots = require('@src/plots/plots'); var d3 = require('d3'); var customMatchers = require('../assets/custom_matchers'); @@ -12,6 +14,45 @@ var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); +describe('shape supplyDefaults', function() { + beforeAll(function() { + jasmine.addMatchers(customMatchers); + }); + + it('should provide the right defaults on all axis types', function() { + var fullLayout = { + xaxis: {type: 'linear', range: [0, 20]}, + yaxis: {type: 'log', range: [1, 5]}, + xaxis2: {type: 'date', range: ['2006-06-05', '2006-06-09']}, + yaxis2: {type: 'category', range: [-0.5, 7.5]} + }; + fullLayout._has = Plots._hasPlotType.bind(fullLayout); + Axes.setConvert(fullLayout.xaxis); + Axes.setConvert(fullLayout.yaxis); + Axes.setConvert(fullLayout.xaxis2); + Axes.setConvert(fullLayout.yaxis2); + + var shape1In = {type: 'rect'}, + shape1Out = handleShapeDefaults(shape1In, fullLayout), + shape2In = {type: 'circle', xref: 'x2', yref: 'y2'}, + shape2Out = handleShapeDefaults(shape2In, fullLayout); + + // default positions are 1/4 and 3/4 of the full range of that axis + expect(shape1Out.x0).toBe(5); + expect(shape1Out.x1).toBe(15); + // shapes use data values for log axes (like everyone will in V2.0) + expect(shape1Out.y0).toBeWithin(100, 0.001); + expect(shape1Out.y1).toBeWithin(10000, 0.001); + // date strings also interpolate + expect(shape2Out.x0).toBe('2006-06-06'); + expect(shape2Out.x1).toBe('2006-06-08'); + // categories must use serial numbers to get continuous values + expect(shape2Out.y0).toBeWithin(1.5, 0.001); + expect(shape2Out.y1).toBeWithin(5.5, 0.001); + }); +}); + + describe('Test shapes:', function() { 'use strict'; diff --git a/test/jasmine/tests/snapshot_test.js b/test/jasmine/tests/snapshot_test.js index d2050628f43..579a3ae58eb 100644 --- a/test/jasmine/tests/snapshot_test.js +++ b/test/jasmine/tests/snapshot_test.js @@ -86,7 +86,8 @@ describe('Plotly.Snapshot', function() { var themeTile = Plotly.Snapshot.clone(dummyGraphObj, themeOptions); expect(themeTile.layout.height).toEqual(THEMETILE_DEFAULT_LAYOUT.height); expect(themeTile.layout.width).toEqual(THEMETILE_DEFAULT_LAYOUT.width); - expect(themeTile.td.defaultLayout).toEqual(THEMETILE_DEFAULT_LAYOUT); + expect(themeTile.gd.defaultLayout).toEqual(THEMETILE_DEFAULT_LAYOUT); + expect(themeTile.gd).toBe(themeTile.td); // image server compatibility expect(themeTile.config).toEqual(config); });