diff --git a/src/plots/polar/layout_attributes.js b/src/plots/polar/layout_attributes.js index ba4d8fe25a6..841f984399a 100644 --- a/src/plots/polar/layout_attributes.js +++ b/src/plots/polar/layout_attributes.js @@ -122,11 +122,6 @@ var radialAxisAttrs = { // span: {}, // hole: 1 - // maybe should add a boolean to enable square grid lines - // and square axis lines - // (most common in radar-like charts) - // e.g. squareline/squaregrid or showline/showgrid: 'square' (on-top of true) - editType: 'calc' }; @@ -272,6 +267,22 @@ module.exports = { radialaxis: radialAxisAttrs, angularaxis: angularAxisAttrs, + gridshape: { + valType: 'enumerated', + values: ['circular', 'linear'], + dflt: 'circular', + role: 'style', + editType: 'plot', + description: [ + 'Determines if the radial axis grid lines and angular axis line are drawn', + 'as *circular* sectors or as *linear* (polygon) sectors.', + 'Has an effect only when the angular axis has `type` *category*.', + 'Note that `radialaxis.angle` is snapped to the angle of the closest', + 'vertex when `gridshape` is *circular*', + '(so that radial axis scale is the same as the data scale).' + ].join(' ') + }, + // TODO maybe? // annotations: diff --git a/src/plots/polar/layout_defaults.js b/src/plots/polar/layout_defaults.js index ec383eafe69..19bf636326a 100644 --- a/src/plots/polar/layout_defaults.js +++ b/src/plots/polar/layout_defaults.js @@ -177,6 +177,10 @@ function handleDefaults(contIn, contOut, coerce, opts) { axOut._input = axIn; } + + if(contOut.angularaxis.type === 'category') { + coerce('gridshape'); + } } function handleAxisTypeDefaults(axIn, axOut, coerce, subplotData, dataAttr) { diff --git a/src/plots/polar/polar.js b/src/plots/polar/polar.js index 5b8264194a3..3a91414d45b 100644 --- a/src/plots/polar/polar.js +++ b/src/plots/polar/polar.js @@ -25,6 +25,7 @@ var Titles = require('../../components/titles'); var prepSelect = require('../cartesian/select').prepSelect; var clearSelect = require('../cartesian/select').clearSelect; var setCursor = require('../../lib/setcursor'); +var polygonTester = require('../../lib/polygon').tester; var MID_SHIFT = require('../../constants/alignment').MID_SHIFT; @@ -42,6 +43,8 @@ function Polar(gd, id) { this.gd = gd; this._hasClipOnAxisFalse = null; + this.vangles = null; + this.radialAxisAngle = null; this.traceHash = {}; this.layers = {}; this.clipPaths = {}; @@ -51,10 +54,10 @@ function Polar(gd, id) { var fullLayout = gd._fullLayout; var clipIdBase = 'clip' + fullLayout._uid + id; - this.clipIds.circle = clipIdBase + '-circle'; - this.clipPaths.circle = fullLayout._clips.append('clipPath') - .attr('id', this.clipIds.circle); - this.clipPaths.circle.append('path'); + this.clipIds.forTraces = clipIdBase + '-for-traces'; + this.clipPaths.forTraces = fullLayout._clips.append('clipPath') + .attr('id', this.clipIds.forTraces); + this.clipPaths.forTraces.append('path'); this.framework = fullLayout._polarlayer.append('g') .attr('class', id); @@ -130,7 +133,7 @@ proto.updateLayers = function(fullLayout, polarLayout) { sel.append('g').classed('maplayer', true); break; case 'plotbg': - layers.bgcircle = sel.append('path'); + layers.bg = sel.append('path'); break; case 'radial-grid': sel.style('fill', 'none'); @@ -207,9 +210,50 @@ proto.updateLayout = function(fullLayout, polarLayout) { var cxx = _this.cxx = cx - xOffset2; var cyy = _this.cyy = cy - yOffset2; + var mockOpts = { + // to get _boundingBox computation right when showticklabels is false + anchor: 'free', + position: 0, + // dummy truthy value to make Axes.doTicksSingle draw the grid + _counteraxis: true, + // don't use automargins routine for labels + automargin: false + }; + + _this.radialAxis = Lib.extendFlat({}, polarLayout.radialaxis, mockOpts, { + _axislayer: layers['radial-axis'], + _gridlayer: layers['radial-grid'], + // make this an 'x' axis to make positioning (especially rotation) easier + _id: 'x', + _pos: 0, + // convert to 'x' axis equivalent + side: { + counterclockwise: 'top', + clockwise: 'bottom' + }[polarLayout.radialaxis.side], + // spans length 1 radius + domain: [0, radius / gs.w] + }); + + _this.angularAxis = Lib.extendFlat({}, polarLayout.angularaxis, mockOpts, { + _axislayer: layers['angular-axis'], + _gridlayer: layers['angular-grid'], + // angular axes need *special* logic + _id: 'angular', + _pos: 0, + side: 'right', + // to get auto nticks right + domain: [0, Math.PI], + // don't pass through autorange logic + autorange: false + }); + + _this.doAutoRange(fullLayout, polarLayout); + // N.B. this sets _this.vangles + _this.updateAngularAxis(fullLayout, polarLayout); + // N.B. this sets _this.radialAxisAngle _this.updateRadialAxis(fullLayout, polarLayout); _this.updateRadialAxisTitle(fullLayout, polarLayout); - _this.updateAngularAxis(fullLayout, polarLayout); var radialRange = _this.radialAxis.range; var rSpan = radialRange[1] - radialRange[0]; @@ -235,25 +279,35 @@ proto.updateLayout = function(fullLayout, polarLayout) { xaxis.isPtWithinRange = function(d) { return _this.isPtWithinSector(d); }; yaxis.isPtWithinRange = function() { return true; }; + _this.clipPaths.forTraces.select('path') + .attr('d', pathSectorClosed(radius, sector, _this.vangles)) + .attr('transform', strTranslate(cxx, cyy)); + layers.frontplot .attr('transform', strTranslate(xOffset2, yOffset2)) - .call(Drawing.setClipUrl, _this._hasClipOnAxisFalse ? null : _this.clipIds.circle); + .call(Drawing.setClipUrl, _this._hasClipOnAxisFalse ? null : _this.clipIds.forTraces); - layers.bgcircle.attr({ - d: pathSectorClosed(radius, sector), - transform: strTranslate(cx, cy) - }) - .call(Color.fill, polarLayout.bgcolor); - - _this.clipPaths.circle.select('path') - .attr('d', pathSectorClosed(radius, sector)) - .attr('transform', strTranslate(cxx, cyy)); + layers.bg + .attr('d', pathSectorClosed(radius, sector, _this.vangles)) + .attr('transform', strTranslate(cx, cy)) + .call(Color.fill, polarLayout.bgcolor); // remove crispEdges - all the off-square angles in polar plots // make these counterproductive. _this.framework.selectAll('.crisp').classed('crisp', 0); }; +proto.doAutoRange = function(fullLayout, polarLayout) { + var radialLayout = polarLayout.radialaxis; + var ax = this.radialAxis; + + setScale(ax, radialLayout, fullLayout); + doAutoRange(ax); + + radialLayout.range = ax.range.slice(); + radialLayout._input.range = ax.range.slice(); +}; + proto.updateRadialAxis = function(fullLayout, polarLayout) { var _this = this; var gd = _this.gd; @@ -261,42 +315,12 @@ proto.updateRadialAxis = function(fullLayout, polarLayout) { var radius = _this.radius; var cx = _this.cx; var cy = _this.cy; - var gs = fullLayout._size; var radialLayout = polarLayout.radialaxis; var sector = polarLayout.sector; var a0 = wrap360(sector[0]); + var ax = _this.radialAxis; _this.fillViewInitialKey('radialaxis.angle', radialLayout.angle); - - var ax = _this.radialAxis = Lib.extendFlat({}, radialLayout, { - _axislayer: layers['radial-axis'], - _gridlayer: layers['radial-grid'], - - // make this an 'x' axis to make positioning (especially rotation) easier - _id: 'x', - _pos: 0, - - // convert to 'x' axis equivalent - side: {counterclockwise: 'top', clockwise: 'bottom'}[radialLayout.side], - - // spans length 1 radius - domain: [0, radius / gs.w], - - // to get _boundingBox computation right when showticklabels is false - anchor: 'free', - position: 0, - - // dummy truthy value to make Axes.doTicksSingle draw the grid - _counteraxis: true, - - // don't use automargins routine for labels - automargin: false - }); - - setScale(ax, radialLayout, fullLayout); - doAutoRange(ax); - radialLayout.range = ax.range.slice(); - radialLayout._input.range = ax.range.slice(); _this.fillViewInitialKey('radialaxis.range', ax.range.slice()); // rotate auto tick labels by 180 if in quadrant II and III to make them @@ -315,7 +339,7 @@ proto.updateRadialAxis = function(fullLayout, polarLayout) { // set special grid path function ax._gridpath = function(d) { var r = ax.r2p(d.x); - return pathSector(r, sector); + return pathSector(r, sector, _this.vangles); }; var newTickLayout = strTickLayout(radialLayout); @@ -326,8 +350,15 @@ proto.updateRadialAxis = function(fullLayout, polarLayout) { Axes.doTicksSingle(gd, ax, true); + // stash 'actual' radial axis angle for drag handlers (in degrees) + var angle = _this.radialAxisAngle = _this.vangles ? + rad2deg(snapToVertexAngle(deg2rad(radialLayout.angle), _this.vangles)) : + radialLayout.angle; + + var trans = strTranslate(cx, cy) + strRotate(-angle); + updateElement(layers['radial-axis'], radialLayout.showticklabels || radialLayout.ticks, { - transform: strTranslate(cx, cy) + strRotate(-radialLayout.angle) + transform: trans }); // move all grid paths to about circle center, @@ -342,7 +373,7 @@ proto.updateRadialAxis = function(fullLayout, polarLayout) { y1: 0, x2: radius, y2: 0, - transform: strTranslate(cx, cy) + strRotate(-radialLayout.angle) + transform: trans }) .attr('stroke-width', radialLayout.linewidth) .call(Color.stroke, radialLayout.linecolor); @@ -357,7 +388,7 @@ proto.updateRadialAxisTitle = function(fullLayout, polarLayout, _angle) { var radialLayout = polarLayout.radialaxis; var titleClass = _this.id + 'title'; - var angle = _angle !== undefined ? _angle : radialLayout.angle; + var angle = _angle !== undefined ? _angle : _this.radialAxisAngle; var angleRad = deg2rad(angle); var cosa = Math.cos(angleRad); var sina = Math.sin(angleRad); @@ -394,34 +425,20 @@ proto.updateAngularAxis = function(fullLayout, polarLayout) { var angularLayout = polarLayout.angularaxis; var sector = polarLayout.sector; var sectorInRad = sector.map(deg2rad); + var ax = _this.angularAxis; _this.fillViewInitialKey('angularaxis.rotation', angularLayout.rotation); - var ax = _this.angularAxis = Lib.extendFlat({}, angularLayout, { - _axislayer: layers['angular-axis'], - _gridlayer: layers['angular-grid'], - - // angular axes need *special* logic - _id: 'angular', - _pos: 0, - side: 'right', - - // to get auto nticks right - domain: [0, Math.PI], - - // to get _boundingBox computation right when showticklabels is false - anchor: 'free', - position: 0, - - // dummy truthy value to make Axes.doTicksSingle draw the grid - _counteraxis: true, - - // don't use automargins routine for labels - automargin: false, + // wrapper around c2rad from setConvertAngular + // note that linear ranges are always set in degrees for Axes.doTicksSingle + function c2rad(d) { + return ax.c2rad(d.x, 'degrees'); + } - // don't pass through autorange logic - autorange: false - }); + // (x,y) at max radius + function rad2xy(rad) { + return [radius * Math.cos(rad), radius * Math.sin(rad)]; + } // Set the angular range in degrees to make auto-tick computation cleaner, // changing rotation/direction should not affect the angular tick labels. @@ -448,28 +465,11 @@ proto.updateAngularAxis = function(fullLayout, polarLayout) { angularLayout._categories.length; ax.range = [0, period]; - - ax._tickFilter = function(d) { - return _this.isPtWithinSector({ - r: _this.radialAxis.range[1], - rad: ax.c2rad(d.x) - }); - }; + ax._tickFilter = function(d) { return isAngleInSector(c2rad(d), sector); }; } setScale(ax, angularLayout, fullLayout); - // wrapper around c2rad from setConvertAngular - // note that linear ranges are always set in degrees for Axes.doTicksSingle - function c2rad(d) { - return ax.c2rad(d.x, 'degrees'); - } - - // (x,y) at max radius - function rad2xy(rad) { - return [radius * Math.cos(rad), radius * Math.sin(rad)]; - } - ax._transfn = function(d) { var rad = c2rad(d); var xy = rad2xy(rad); @@ -532,8 +532,24 @@ proto.updateAngularAxis = function(fullLayout, polarLayout) { Axes.doTicksSingle(gd, ax, true); + // angle of polygon vertices in radians (null means circles) + // TODO what to do when ax.period > ax._categories ?? + var vangles; + if(polarLayout.gridshape === 'linear') { + vangles = ax._vals.map(c2rad); + + // ax._vals should be always ordered, make them + // always turn counterclockwise for convenience here + if(angleDelta(vangles[0], vangles[1]) < 0) { + vangles = vangles.slice().reverse(); + } + } else { + vangles = null; + } + _this.vangles = vangles; + updateElement(layers['angular-line'].select('path'), angularLayout.showline, { - d: pathSectorClosed(radius, sector), + d: pathSectorClosed(radius, sector, vangles), transform: strTranslate(cx, cy) }) .attr('stroke-width', angularLayout.linewidth) @@ -561,11 +577,14 @@ proto.updateMainDrag = function(fullLayout, polarLayout) { var cxx = _this.cxx; var cyy = _this.cyy; var sector = polarLayout.sector; + var vangles = _this.vangles; + var chw = constants.cornerHalfWidth; + var chl = constants.cornerLen / 2; var mainDrag = dragBox.makeDragger(layers, 'path', 'maindrag', 'crosshair'); d3.select(mainDrag) - .attr('d', pathSectorClosed(radius, sector)) + .attr('d', pathSectorClosed(radius, sector, vangles)) .attr('transform', strTranslate(cx, cy)); var dragOpts = { @@ -589,10 +608,12 @@ proto.updateMainDrag = function(fullLayout, polarLayout) { // zoombox, corners elements var zb, corners; + function norm(x, y) { + return Math.sqrt(x * x + y * y); + } + function xy2r(x, y) { - var xx = x - cxx; - var yy = y - cyy; - return Math.sqrt(xx * xx + yy * yy); + return norm(x - cxx, y - cyy); } function xy2a(x, y) { @@ -603,13 +624,14 @@ proto.updateMainDrag = function(fullLayout, polarLayout) { return [r * Math.cos(a), r * Math.sin(-a)]; } - function pathCorner(r, a) { - var clen = constants.cornerLen; - var chw = constants.cornerHalfWidth; + function _pathSectorClosed(r) { + return pathSectorClosed(r, sector, vangles); + } - if(r === 0) return pathSectorClosed(2 * chw, sector); + function pathCorner(r, a) { + if(r === 0) return _pathSectorClosed(2 * chw); - var da = clen / r / 2; + var da = chl / r; var am = a - da; var ap = a + da; var rb = Math.max(0, Math.min(r, radius)); @@ -623,10 +645,48 @@ proto.updateMainDrag = function(fullLayout, polarLayout) { 'Z'; } + // (x,y) is the pt at middle of the va0 <-> va1 edge + // + // ... we could eventually add another mode for cursor + // angles 'close to' enough to a particular vertex. + function pathCornerForPolygons(r, va0, va1) { + if(r === 0) return _pathSectorClosed(2 * chw); + + var xy0 = ra2xy(r, va0); + var xy1 = ra2xy(r, va1); + var x = clampTiny((xy0[0] + xy1[0]) / 2); + var y = clampTiny((xy0[1] + xy1[1]) / 2); + var innerPts, outerPts; + + if(x && y) { + var m = y / x; + var mperp = -1 / m; + var midPts = findXYatLength(chw, m, x, y); + innerPts = findXYatLength(chl, mperp, midPts[0][0], midPts[0][1]); + outerPts = findXYatLength(chl, mperp, midPts[1][0], midPts[1][1]); + } else { + var dx, dy; + if(y) { + // horizontal handles + dx = chl; + dy = chw; + } else { + // vertical handles + dx = chw; + dy = chl; + } + innerPts = [[x - dx, y - dy], [x + dx, y - dy]]; + outerPts = [[x - dx, y + dy], [x + dx, y + dy]]; + } + + return 'M' + innerPts.join('L') + + 'L' + outerPts.reverse().join('L') + 'Z'; + } + function zoomPrep() { r0 = null; r1 = null; - path0 = pathSectorClosed(radius, sector); + path0 = _pathSectorClosed(radius); dimmed = false; var polarLayoutNow = gd._fullLayout[_this.id]; @@ -638,13 +698,10 @@ proto.updateMainDrag = function(fullLayout, polarLayout) { clearSelect(zoomlayer); } - function zoomMove(dx, dy) { - var x1 = x0 + dx; - var y1 = y0 + dy; - var rr0 = xy2r(x0, y0); - var rr1 = Math.min(xy2r(x1, y1), radius); - var a0 = xy2a(x0, y0); - var a1 = xy2a(x1, y1); + // N.B. this sets scoped 'r0' and 'r1' + // return true if 'valid' zoom distance, false otherwise + function clampAndSetR0R1(rr0, rr1) { + rr1 = Math.min(rr1, radius); // starting or ending drag near center (outer edge), // clamps radial distance at origin (at r=radius) @@ -653,29 +710,27 @@ proto.updateMainDrag = function(fullLayout, polarLayout) { else if(rr1 < OFFEDGE) rr1 = 0; else if((radius - rr1) < OFFEDGE) rr1 = radius; - var path1; - var cpath; - + // make sure r0 < r1, + // to get correct fill pattern in path1 below if(Math.abs(rr1 - rr0) > MINZOOM) { - // make sure r0 < r1, - // to get correct fill pattern in path1 below if(rr0 < rr1) { r0 = rr0; r1 = rr1; } else { r0 = rr1; r1 = rr0; - a1 = [a0, a0 = a1][0]; // swap a0 and a1 } - - path1 = path0 + pathSectorClosed(r1, sector) + pathSectorClosed(r0, sector); - cpath = pathCorner(r0, a0) + pathCorner(r1, a1); + return true; } else { r0 = null; r1 = null; - path1 = path0; - cpath = 'M0,0Z'; + return false; } + } + + function applyZoomMove(path1, cpath) { + path1 = path1 || path0; + cpath = cpath || 'M0,0Z'; zb.attr('d', path1); corners.attr('d', cpath); @@ -683,6 +738,60 @@ proto.updateMainDrag = function(fullLayout, polarLayout) { dimmed = true; } + function zoomMove(dx, dy) { + var x1 = x0 + dx; + var y1 = y0 + dy; + var rr0 = xy2r(x0, y0); + var rr1 = Math.min(xy2r(x1, y1), radius); + var a0 = xy2a(x0, y0); + var path1; + var cpath; + + if(clampAndSetR0R1(rr0, rr1)) { + path1 = path0 + _pathSectorClosed(r1) + _pathSectorClosed(r0); + // keep 'starting' angle + cpath = pathCorner(r0, a0) + pathCorner(r1, a0); + } + applyZoomMove(path1, cpath); + } + + function findEnclosingVertexAngles(a) { + var i0 = findIndexOfMin(vangles, function(v) { + var adelta = angleDelta(v, a); + return adelta > 0 ? adelta : Infinity; + }); + var i1 = Lib.mod(i0 + 1, vangles.length); + return [vangles[i0], vangles[i1]]; + } + + function findPolygonRadius(x, y, va0, va1) { + var xy = findIntersectionXY(va0, va1, va0, [x - cxx, cyy - y]); + return norm(xy[0], xy[1]); + } + + function zoomMoveForPolygons(dx, dy) { + var x1 = x0 + dx; + var y1 = y0 + dy; + var a0 = xy2a(x0, y0); + var a1 = xy2a(x1, y1); + var vangles0 = findEnclosingVertexAngles(a0); + var vangles1 = findEnclosingVertexAngles(a1); + var rr0 = findPolygonRadius(x0, y0, vangles0[0], vangles0[1]); + var rr1 = Math.min(findPolygonRadius(x1, y1, vangles1[0], vangles1[1]), radius); + var path1; + var cpath; + + if(clampAndSetR0R1(rr0, rr1)) { + path1 = path0 + _pathSectorClosed(r1) + _pathSectorClosed(r0); + // keep 'starting' angle here too + cpath = [ + pathCornerForPolygons(r0, vangles0[0], vangles0[1]), + pathCornerForPolygons(r1, vangles0[0], vangles0[1]) + ].join(' '); + } + applyZoomMove(path1, cpath); + } + function zoomDone() { dragBox.removeZoombox(gd); @@ -709,9 +818,21 @@ proto.updateMainDrag = function(fullLayout, polarLayout) { x0 = startX - bbox.left; y0 = startY - bbox.top; + // need to offset x/y as bbox center does not + // match origin for asymmetric polygons + if(vangles) { + var offset = findPolygonOffset(radius, sector, vangles); + x0 += cxx + offset[0]; + y0 += cyy + offset[1]; + } + switch(dragModeNow) { case 'zoom': - dragOpts.moveFn = zoomMove; + if(vangles) { + dragOpts.moveFn = zoomMoveForPolygons; + } else { + dragOpts.moveFn = zoomMove; + } dragOpts.doneFn = zoomDone; zoomPrep(evt, startX, startY); break; @@ -762,7 +883,7 @@ proto.updateRadialDrag = function(fullLayout, polarLayout) { var cy = _this.cy; var radialAxis = _this.radialAxis; var radialLayout = polarLayout.radialaxis; - var angle0 = deg2rad(radialLayout.angle); + var angle0 = deg2rad(_this.radialAxisAngle); var range0 = radialAxis.range.slice(); var drange = range0[1] - range0[0]; var bl = constants.radialDragBoxSize; @@ -813,7 +934,9 @@ proto.updateRadialDrag = function(fullLayout, polarLayout) { var x1 = tx + dx; var y1 = ty + dy; - angle1 = rad2deg(Math.atan2(cy - y1, x1 - cx)); + angle1 = Math.atan2(cy - y1, x1 - cx); + if(_this.vangles) angle1 = snapToVertexAngle(angle1, _this.vangles); + angle1 = rad2deg(angle1); var transform = strTranslate(cx, cy) + strRotate(-angle1); layers['radial-axis'].attr('transform', transform); @@ -897,9 +1020,19 @@ proto.updateAngularDrag = function(fullLayout, polarLayout) { var angularDrag = dragBox.makeDragger(layers, 'path', 'angulardrag', 'move'); var dragOpts = {element: angularDrag, gd: gd}; + var angularDragPath; + + if(_this.vangles) { + // use evenodd svg rule + var outer = invertY(makePolygon(radius + dbs, sector, _this.vangles)); + var inner = invertY(makePolygon(radius, sector, _this.vangles)); + angularDragPath = 'M' + outer.reverse().join('L') + 'M' + inner.join('L'); + } else { + angularDragPath = pathAnnulus(radius, radius + dbs, sector); + } d3.select(angularDrag) - .attr('d', pathAnnulus(radius, radius + dbs, sector)) + .attr('d', angularDragPath) .attr('transform', strTranslate(cx, cy)) .call(setCursor, 'move'); @@ -916,12 +1049,16 @@ proto.updateAngularDrag = function(fullLayout, polarLayout) { var x0, y0; // angular axis angle rotation at drag start (0), move (1) var rot0, rot1; + // induced radial axis rotation (only used on polygon grids) + var rrot1; // copy of polar sector value at drag start var sector0; // angle about circle center at drag start var a0; function moveFn(dx, dy) { + var fullLayoutNow = _this.gd._fullLayout; + var polarLayoutNow = fullLayoutNow[_this.id]; var x1 = x0 + dx; var y1 = y0 + dy; var a1 = xy2a(x1, y1); @@ -932,9 +1069,23 @@ proto.updateAngularDrag = function(fullLayout, polarLayout) { strTranslate(_this.xOffset2, _this.yOffset2) + strRotate([-da, cxx, cyy]) ); - _this.clipPaths.circle.select('path').attr('transform', - strTranslate(cxx, cyy) + strRotate(da) - ); + if(_this.vangles) { + rrot1 = _this.radialAxisAngle + da; + + var trans = strTranslate(cx, cy) + strRotate(-da); + var trans2 = strTranslate(cx, cy) + strRotate(-rrot1); + + layers.bg.attr('transform', trans); + layers['radial-grid'].attr('transform', trans); + layers['angular-line'].select('path').attr('transform', trans); + layers['radial-axis'].attr('transform', trans2); + layers['radial-line'].select('line').attr('transform', trans2); + _this.updateRadialAxisTitle(fullLayoutNow, polarLayoutNow, rrot1); + } else { + _this.clipPaths.forTraces.select('path').attr('transform', + strTranslate(cxx, cyy) + strRotate(da) + ); + } // 'un-rotate' marker and text points scatterPoints.each(function() { @@ -974,8 +1125,6 @@ proto.updateAngularDrag = function(fullLayout, polarLayout) { var moduleCalcData = _this.traceHash[k]; var moduleCalcDataVisible = Lib.filterVisible(moduleCalcData); var _module = moduleCalcData[0][0].trace._module; - var polarLayoutNow = gd._fullLayout[_this.id]; - _module.plot(gd, _this, moduleCalcDataVisible, polarLayoutNow); } } @@ -983,8 +1132,14 @@ proto.updateAngularDrag = function(fullLayout, polarLayout) { function doneFn() { scatterTextPoints.select('text').attr('transform', null); + var updateObj = {}; updateObj[_this.id + '.angularaxis.rotation'] = rot1; + + if(_this.vangles) { + updateObj[_this.id + '.radialaxis.angle'] = rrot1; + } + Registry.call('relayout', gd, updateObj); } @@ -1004,22 +1159,27 @@ proto.updateAngularDrag = function(fullLayout, polarLayout) { clearSelect(fullLayout._zoomlayer); }; + // I don't what we should do in this case, skip we now + if(_this.vangles && !isFullCircle(sector)) { + dragOpts.prepFn = Lib.noop; + setCursor(d3.select(angularDrag), null); + } + dragElement.init(dragOpts); }; proto.isPtWithinSector = function(d) { var sector = this.sector; + + if(!isAngleInSector(d.rad, sector)) { + return false; + } + + var vangles = this.vangles; var radialAxis = this.radialAxis; var radialRange = radialAxis.range; var r = radialAxis.c2r(d.r); - var s0 = wrap360(sector[0]); - var s1 = wrap360(sector[1]); - if(s0 > s1) s1 += 360; - - var deg = wrap360(rad2deg(d.rad)); - var nextTurnDeg = deg + 360; - var r0, r1; if(radialRange[1] >= radialRange[0]) { r0 = radialRange[0]; @@ -1029,13 +1189,14 @@ proto.isPtWithinSector = function(d) { r1 = radialRange[0]; } - return ( - (r >= r0 && r <= r1) && - (isFullCircle(sector) || - (deg >= s0 && deg <= s1) || - (nextTurnDeg >= s0 && nextTurnDeg <= s1) - ) - ); + if(vangles) { + var polygonIn = polygonTester(makePolygon(r0, sector, vangles)); + var polygonOut = polygonTester(makePolygon(r1, sector, vangles)); + var xy = [r * Math.cos(d.rad), r * Math.sin(d.rad)]; + return polygonOut.contains(xy) && !polygonIn.contains(xy); + } + + return r >= r0 && r <= r1; }; proto.fillViewInitialKey = function(key, val) { @@ -1116,26 +1277,236 @@ function computeSectorBBox(sector) { return [x0, y0, x1, y1]; } -function pathSector(r, sector) { - if(isFullCircle(sector)) { - return Drawing.symbolFuncs[0](r); +function isAngleInSector(rad, sector) { + if(isFullCircle(sector)) return true; + + var s0 = wrap360(sector[0]); + var s1 = wrap360(sector[1]); + if(s0 > s1) s1 += 360; + + var deg = wrap360(rad2deg(rad)); + var nextTurnDeg = deg + 360; + + return (deg >= s0 && deg <= s1) || + (nextTurnDeg >= s0 && nextTurnDeg <= s1); +} + +function snapToVertexAngle(a, vangles) { + function angleDeltaAbs(va) { + return Math.abs(angleDelta(a, va)); } - var xs = r * Math.cos(deg2rad(sector[0])); - var ys = -r * Math.sin(deg2rad(sector[0])); - var xe = r * Math.cos(deg2rad(sector[1])); - var ye = -r * Math.sin(deg2rad(sector[1])); + var ind = findIndexOfMin(vangles, angleDeltaAbs); + return vangles[ind]; +} - var arc = Math.abs(sector[1] - sector[0]); - var flags = arc <= 180 ? [0, 0, 0] : [0, 1, 0]; +// taken from https://stackoverflow.com/a/2007279 +function angleDelta(a, b) { + var d = b - a; + return Math.atan2(Math.sin(d), Math.cos(d)); +} + +function findIndexOfMin(arr, fn) { + fn = fn || Lib.identity; + + var min = Infinity; + var ind; + + for(var i = 0; i < arr.length; i++) { + var v = fn(arr[i]); + if(v < min) { + min = v; + ind = i; + } + } + return ind; +} + +// find intersection of 'v0' <-> 'v1' edge with a ray at angle 'a' +// (i.e. a line that starts from the origin at angle 'a') +// given an (xp,yp) pair on the 'v0' <-> 'v1' line +// (N.B. 'v0' and 'v1' are angles in radians) +function findIntersectionXY(v0, v1, a, xpyp) { + var xstar, ystar; + + var xp = xpyp[0]; + var yp = xpyp[1]; + var dsin = clampTiny(Math.sin(v1) - Math.sin(v0)); + var dcos = clampTiny(Math.cos(v1) - Math.cos(v0)); + var tanA = Math.tan(a); + var cotanA = clampTiny(1 / tanA); + var m = dsin / dcos; + var b = yp - m * xp; + + if(cotanA) { + if(dsin && dcos) { + // given + // g(x) := v0 -> v1 line = m*x + b + // h(x) := ray at angle 'a' = m*x = tanA*x + // solve g(xstar) = h(xstar) + xstar = b / (tanA - m); + ystar = tanA * xstar; + } else if(dcos) { + // horizontal v0 -> v1 + xstar = yp * cotanA; + ystar = yp; + } else { + // vertical v0 -> v1 + xstar = xp; + ystar = xp * tanA; + } + } else { + // vertical ray + if(dsin && dcos) { + xstar = 0; + ystar = b; + } else if(dcos) { + xstar = 0; + ystar = yp; + } else { + // does this case exists? + xstar = ystar = NaN; + } + } + + return [xstar, ystar]; +} + +// solves l^2 = (f(x)^2 - yp)^2 + (x - xp)^2 +// rearranged into 0 = a*x^2 + b * x + c +// +// where f(x) = m*x + t + yp +// and (x0, x1) = (-b +/- del) / (2*a) +function findXYatLength(l, m, xp, yp) { + var t = -m * xp; + var a = m * m + 1; + var b = 2 * (m * t - xp); + var c = t * t + xp * xp - l * l; + var del = Math.sqrt(b * b - 4 * a * c); + var x0 = (-b + del) / (2 * a); + var x1 = (-b - del) / (2 * a); + return [ + [x0, m * x0 + t + yp], + [x1, m * x1 + t + yp] + ]; +} - return 'M' + [xs, ys] + - 'A' + [r, r] + ' ' + flags + ' ' + [xe, ye]; +function makeRegularPolygon(r, vangles) { + var len = vangles.length; + var vertices = new Array(len + 1); + var i; + for(i = 0; i < len; i++) { + var va = vangles[i]; + vertices[i] = [r * Math.cos(va), r * Math.sin(va)]; + } + vertices[i] = vertices[0].slice(); + return vertices; +} + +function makeClippedPolygon(r, sector, vangles) { + var len = vangles.length; + var vertices = []; + var i, j; + + function a2xy(a) { + return [r * Math.cos(a), r * Math.sin(a)]; + } + + function findXY(va0, va1, s) { + return findIntersectionXY(va0, va1, s, a2xy(va0)); + } + + function cycleIndex(ind) { + return Lib.mod(ind, len); + } + + var s0 = deg2rad(sector[0]); + var s1 = deg2rad(sector[1]); + + // find index in sector closest to sector[0], + // use it to find intersection of v[i0] <-> v[i0-1] edge with sector radius + var i0 = findIndexOfMin(vangles, function(v) { + return isAngleInSector(v, sector) ? Math.abs(angleDelta(v, s0)) : Infinity; + }); + var xy0 = findXY(vangles[i0], vangles[cycleIndex(i0 - 1)], s0); + vertices.push(xy0); + + // fill in in-sector vertices + for(i = i0, j = 0; j < len; i++, j++) { + var va = vangles[cycleIndex(i)]; + if(!isAngleInSector(va, sector)) break; + vertices.push(a2xy(va)); + } + + // find index in sector closest to sector[1], + // use it to find intersection of v[iN] <-> v[iN+1] edge with sector radius + var iN = findIndexOfMin(vangles, function(v) { + return isAngleInSector(v, sector) ? Math.abs(angleDelta(v, s1)) : Infinity; + }); + var xyN = findXY(vangles[iN], vangles[cycleIndex(iN + 1)], s1); + vertices.push(xyN); + + vertices.push([0, 0]); + vertices.push(vertices[0].slice()); + + return vertices; } -function pathSectorClosed(r, sector) { - return pathSector(r, sector) + - (isFullCircle(sector) ? '' : 'L0,0Z'); +function makePolygon(r, sector, vangles) { + return isFullCircle(sector) ? + makeRegularPolygon(r, vangles) : + makeClippedPolygon(r, sector, vangles); +} + +function findPolygonOffset(r, sector, vangles) { + var minX = Infinity; + var minY = Infinity; + var vertices = makePolygon(r, sector, vangles); + + for(var i = 0; i < vertices.length; i++) { + var v = vertices[i]; + minX = Math.min(minX, v[0]); + minY = Math.min(minY, -v[1]); + } + return [minX, minY]; +} + +function invertY(pts0) { + var len = pts0.length; + var pts1 = new Array(len); + for(var i = 0; i < len; i++) { + var pt = pts0[i]; + pts1[i] = [pt[0], -pt[1]]; + } + return pts1; +} + +function pathSector(r, sector, vangles) { + var d; + + if(vangles) { + d = 'M' + invertY(makePolygon(r, sector, vangles)).join('L'); + } else if(isFullCircle(sector)) { + d = Drawing.symbolFuncs[0](r); + } else { + var arc = Math.abs(sector[1] - sector[0]); + var flags = arc <= 180 ? [0, 0, 0] : [0, 1, 0]; + var xs = r * Math.cos(deg2rad(sector[0])); + var ys = -r * Math.sin(deg2rad(sector[0])); + var xe = r * Math.cos(deg2rad(sector[1])); + var ye = -r * Math.sin(deg2rad(sector[1])); + + d = 'M' + [xs, ys] + + 'A' + [r, r] + ' ' + flags + ' ' + [xe, ye]; + } + + return d; +} + +function pathSectorClosed(r, sector, vangles) { + var d = pathSector(r, sector, vangles); + if(isFullCircle(sector) || vangles) return d; + return d + 'L0,0Z'; } // TODO recycle this routine with the ones used for pie traces. @@ -1199,6 +1570,11 @@ function strRotate(angle) { return 'rotate(' + angle + ')'; } +// to more easily catch 'almost zero' numbers in if-else blocks +function clampTiny(v) { + return Math.abs(v) > 1e-10 ? v : 0; +} + // because Math.sign(Math.cos(Math.PI / 2)) === 1 // oh javascript ;) function sign(v) { diff --git a/src/traces/scatterpolar/plot.js b/src/traces/scatterpolar/plot.js index 5ad3a019bef..e0018ddfc85 100644 --- a/src/traces/scatterpolar/plot.js +++ b/src/traces/scatterpolar/plot.js @@ -18,7 +18,7 @@ module.exports = function plot(gd, subplot, moduleCalcData) { xaxis: subplot.xaxis, yaxis: subplot.yaxis, plot: subplot.framework, - layerClipId: subplot._hasClipOnAxisFalse ? subplot.clipIds.circle : null + layerClipId: subplot._hasClipOnAxisFalse ? subplot.clipIds.forTraces : null }; var radialAxis = subplot.radialAxis; diff --git a/test/image/baselines/polar_polygon-grids.png b/test/image/baselines/polar_polygon-grids.png new file mode 100644 index 00000000000..8a6e41ffdef Binary files /dev/null and b/test/image/baselines/polar_polygon-grids.png differ diff --git a/test/image/mocks/polar_polygon-grids.json b/test/image/mocks/polar_polygon-grids.json new file mode 100644 index 00000000000..4936f1e6388 --- /dev/null +++ b/test/image/mocks/polar_polygon-grids.json @@ -0,0 +1,141 @@ +{ + "data": [ + { + "type": "scatterpolar", + "r": [5, 4, 2, 4, 5], + "theta": ["a", "b", "c", "d", "a"], + "fill": "toself" + }, + { + "type": "scatterpolar", + "r": [1, 2.2, 2, 1.5, 1.5, 2, 3, 1], + "theta": ["a", "b", "c", "d", "e", "f", "g", "a"], + "fill": "toself", + "subplot": "polar2" + }, + { + "type": "scatterpolar", + "r": [5, 4, 2, 4, 5], + "theta": ["a", "b", "c", "d", "a"], + "fill": "toself", + "subplot": "polar3" + }, + { + "type": "scatterpolar", + "r": [45, 90, 180, 200, 300, 15, 20, 45], + "theta": ["a", "b", "c", "d", "b", "f", "a", "a"], + "subplot": "polar4" + }, + { + "type": "scatterpolar", + "r": [5, 4, 2, 4, 5, 5], + "theta": ["b", "c", "d", "e", "a", "b"], + "fill": "toself" + }, + { + "type": "scatterpolar", + "r": [500], + "theta": ["b"], + "name": "out-of-polygon (should not see it)", + "subplot": "polar4" + }, + { + "type": "scatterpolar", + "r": [5, 4, 2, 4, 5, 5], + "theta": ["b", "c", "d", "e", "a", "b"], + "fill": "toself", + "subplot": "polar5" + }, + { + "type": "scatterpolar", + "r": [5, 4, 2, 4, 5, 5], + "theta": ["b", "c", "d", "e", "a", "b"], + "fill": "toself", + "subplot": "polar6" + } + ], + "layout": { + "polar": { + "gridshape": "linear", + "domain": { + "x": [0, 0.46], + "y": [0.56, 1] + }, + "radialaxis": { + "angle": 45, + "title": "angular period > _categories" + }, + "angularaxis": { + "direction": "clockwise", + "period": 6 + } + }, + "polar2": { + "gridshape": "linear", + "domain": { + "x": [0, 0.46], + "y": [0, 0.44] + }, + "angularaxis": { + "direction": "clockwise" + } + }, + "polar3": { + "gridshape": "linear", + "domain": { + "x": [0.54, 1], + "y": [0.56, 1] + }, + "sector": [-20, 200], + "radialaxis": { + "angle": 60, + "title": "snapped to 90" + }, + "angularaxis": { + "categoryarray": ["d", "a", "c", "b"], + "direction": "clockwise" + } + }, + "polar4": { + "gridshape": "linear", + "bgcolor": "#d3d3d3", + "domain": { + "x": [0.54, 1], + "y": [0, 0.44] + }, + "angularaxis": { + "direction": "clockwise" + }, + "radialaxis": { + "range": [0, 400] + } + }, + "polar5": { + "gridshape": "linear", + "domain": { + "x": [0.4, 0.6], + "y": [0.4, 0.6] + }, + "angularaxis": { + "direction": "clockwise", + "tickfont": {"color": "red"} + }, + "sector": [-90, 90] + }, + "polar6": { + "gridshape": "linear", + "domain": { + "x": [0.8, 1], + "y": [0.45, 0.65] + }, + "angularaxis": { + "tickfont": {"color": "blue" } + }, + "sector": [0, 180] + }, + "width": 550, + "height": 550, + "margin": {"l": 40, "r": 40, "b": 60, "t": 20, "pad": 0}, + "showlegend": false + } +} diff --git a/test/jasmine/assets/drag.js b/test/jasmine/assets/drag.js index 35b628e0e07..10c8320ce91 100644 --- a/test/jasmine/assets/drag.js +++ b/test/jasmine/assets/drag.js @@ -7,7 +7,7 @@ var getNodeCoords = require('./get_node_coords'); * optionally specify an edge ('n', 'se', 'w' etc) * to grab it by an edge or corner (otherwise the middle is used) */ -module.exports = function(node, dx, dy, edge, x0, y0, nsteps) { +function drag(node, dx, dy, edge, x0, y0, nsteps) { nsteps = nsteps || 1; var coords = getNodeCoords(node, edge); @@ -32,7 +32,7 @@ module.exports = function(node, dx, dy, edge, x0, y0, nsteps) { }); return promise; -}; +} function waitForDragCover() { return new Promise(function(resolve) { @@ -75,3 +75,7 @@ function waitForDragCoverRemoval() { }, interval); }); } + +module.exports = drag; +drag.waitForDragCover = waitForDragCover; +drag.waitForDragCoverRemoval = waitForDragCoverRemoval; diff --git a/test/jasmine/tests/polar_test.js b/test/jasmine/tests/polar_test.js index 32ca0d61b6b..f7fb263a07c 100644 --- a/test/jasmine/tests/polar_test.js +++ b/test/jasmine/tests/polar_test.js @@ -6,7 +6,7 @@ var constants = require('@src/plots/polar/constants'); var d3 = require('d3'); var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); -var fail = require('../assets/fail_test'); +var failTest = require('../assets/fail_test'); var mouseEvent = require('../assets/mouse_event'); var click = require('../assets/click'); var doubleClick = require('../assets/double_click'); @@ -52,7 +52,7 @@ describe('Test legacy polar plots logs:', function() { expect(Lib.log).toHaveBeenCalledTimes(1); expect(Lib.log).toHaveBeenCalledWith('Legacy polar charts are deprecated!'); }) - .catch(fail) + .catch(failTest) .then(done); }); }); @@ -265,7 +265,7 @@ describe('Test relayout on polar subplots:', function() { .then(function() { _assert(dflt); }) - .catch(fail) + .catch(failTest) .then(done); }); @@ -295,7 +295,7 @@ describe('Test relayout on polar subplots:', function() { expect(gd._fullLayout.polar.radialaxis.range) .toBeCloseToArray([0, 11.225]); }) - .catch(fail) + .catch(failTest) .then(done); }); @@ -321,7 +321,7 @@ describe('Test relayout on polar subplots:', function() { // if they're the same, the tick label position did not update expect(pos1).not.toBeCloseTo2DArray(pos0); }) - .catch(fail) + .catch(failTest) .then(done); }); @@ -357,7 +357,7 @@ describe('Test relayout on polar subplots:', function() { .then(function() { check(8, 'M-1.5,0h-5'); }) - .catch(fail) + .catch(failTest) .then(done); }); @@ -450,7 +450,7 @@ describe('Test relayout on polar subplots:', function() { '.angular-axis > path.angulartick', assertCnt ); }) - .catch(fail) + .catch(failTest) .then(done); }); @@ -506,7 +506,7 @@ describe('Test relayout on polar subplots:', function() { .then(function() { assertTitle('yo2', false); }) - .catch(fail) + .catch(failTest) .then(done); }); @@ -522,7 +522,7 @@ describe('Test relayout on polar subplots:', function() { var clipCnt = 0; d3.selectAll('clipPath').each(function() { - if(/polar-circle$/.test(this.id)) clipCnt++; + if(/polar-for-traces/.test(this.id)) clipCnt++; }); expect(clipCnt).toBe(exp.clip, '# clip paths'); } @@ -540,7 +540,7 @@ describe('Test relayout on polar subplots:', function() { .then(function() { _assert({subplot: 1, clip: 1, rtitle: 1}); }) - .catch(fail) + .catch(failTest) .then(done); }); @@ -599,7 +599,46 @@ describe('Test relayout on polar subplots:', function() { sampleXY: [-25, 43] }); }) - .catch(fail) + .catch(failTest) + .then(done); + }); + + it('should be able to relayout *gridshape*', function(done) { + var gd = createGraphDiv(); + + // check number of arcs ('A') or lines ('L') in svg paths + function _assert(msg, exp) { + var sp = d3.select(gd).select('g.polar'); + + function assertLetterCount(query) { + var d = sp.select(query).attr('d'); + var re = new RegExp(exp.letter, 'g'); + var actual = (d.match(re) || []).length; + expect(actual).toBe(exp.cnt, msg + ' - ' + query); + } + + assertLetterCount('.plotbg > path'); + assertLetterCount('.radial-grid > .x > path'); + assertLetterCount('.angular-line > path'); + } + + Plotly.plot(gd, [{ + type: 'scatterpolar', + r: [1, 2, 3, 2, 3, 1], + theta: ['a', 'b', 'c', 'd', 'e', 'a'] + }]) + .then(function() { + _assert('base', {letter: 'A', cnt: 2}); + return Plotly.relayout(gd, 'polar.gridshape', 'linear'); + }) + .then(function() { + _assert('relayout -> linear', {letter: 'L', cnt: 5}); + return Plotly.relayout(gd, 'polar.gridshape', 'circular'); + }) + .then(function() { + _assert('relayout -> circular', {letter: 'A', cnt: 2}); + }) + .catch(failTest) .then(done); }); }); @@ -794,7 +833,7 @@ describe('Test polar interactions:', function() { plotly_relayout: 1 }, 'after right click'); }) - .catch(fail) + .catch(failTest) .then(done); }); @@ -885,7 +924,7 @@ describe('Test polar interactions:', function() { expect(eventCnts.plotly_relayout) .toBe(relayoutNumber, 'no new relayout events after *not far enough* cases'); }) - .catch(fail) + .catch(failTest) .then(done); }); @@ -969,7 +1008,7 @@ describe('Test polar interactions:', function() { .then(function() { expect(eventCnts.plotly_relayout).toBe(8, 'total # of relayout events'); }) - .catch(fail) + .catch(failTest) .then(done); }); @@ -1034,7 +1073,268 @@ describe('Test polar interactions:', function() { .then(function() { expect(eventCnts.plotly_relayout).toBe(4, 'total # of relayout events'); }) - .catch(fail) + .catch(failTest) + .then(done); + }); +}); + +describe('Test polar *gridshape linear* interactions', function() { + var gd; + + beforeEach(function() { + jasmine.DEFAULT_TIMEOUT_INTERVAL = 5000; + gd = createGraphDiv(); + }); + + afterEach(destroyGraphDiv); + + it('should snap radial axis rotation to polygon vertex angles', function(done) { + var dragPos0 = [150, 25]; + var dragPos1 = [316, 82]; + var evtCnt = 0; + + // use 'special' drag method - as we need two mousemove events + // to activate the radial drag mode + function _drag(p0, dp) { + var node = d3.select('.polar > .draglayer > .radialdrag').node(); + return drag(node, dp[0], dp[1], null, p0[0], p0[1], 2); + } + + function _assert(msg, angle) { + expect(gd._fullLayout.polar.radialaxis.angle) + .toBeCloseTo(angle, 1, msg + ' - angle'); + } + + Plotly.plot(gd, [{ + type: 'scatterpolar', + // octogons have nice angles + r: [1, 2, 3, 2, 3, 1, 2, 1, 2], + theta: ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'a'] + }], { + polar: { + gridshape: 'linear', + angularaxis: {direction: 'clockwise'}, + radialaxis: {angle: 90} + }, + width: 400, + height: 400, + margin: {l: 50, t: 50, b: 50, r: 50}, + // to avoid dragging on hover labels + hovermode: false + }) + .then(function() { + gd.on('plotly_relayout', function() { evtCnt++; }); + }) + .then(function() { _assert('base', 90); }) + .then(function() { return _drag(dragPos0, [100, 50]); }) + .then(function() { _assert('rotate right', 45); }) + .then(function() { return _drag(dragPos1, [20, 20]); }) + .then(function() { _assert('rotate right, snapped back', 45); }) + .then(function() { return _drag(dragPos1, [-100, -50]); }) + .then(function() { _assert('rotate left', 90); }) + .then(function() { expect(evtCnt).toBe(3); }) + .catch(failTest) + .then(done); + }); + + it('should rotate all non-symmetrical layers on angular drag', function(done) { + var evtCnt = 0; + var evtData = {}; + var dragCoverNode; + var p1; + + var layersRotateFromZero = ['.plotbg > path', '.radial-grid', '.angular-line > path']; + var layersRotateFromRadialAxis = ['.radial-axis', '.radial-line > line']; + + function _assertTransformRotate(msg, query, rot) { + var sp = d3.select(gd).select('g.polar'); + var t = sp.select(query).attr('transform'); + var rotate = (t.split('rotate(')[1] || '').split(')')[0]; + if(rot === null) { + expect(rotate).toBe('', msg + ' - ' + query); + } else { + expect(Number(rotate)).toBeCloseTo(rot, 1, msg + ' - ' + query); + } + } + + function _dragStart(p0, dp) { + var node = d3.select('.polar > .draglayer > .angulardrag').node(); + mouseEvent('mousemove', p0[0], p0[1], {element: node}); + mouseEvent('mousedown', p0[0], p0[1], {element: node}); + + var promise = drag.waitForDragCover().then(function(dcn) { + dragCoverNode = dcn; + p1 = [p0[0] + dp[0], p0[1] + dp[1]]; + mouseEvent('mousemove', p1[0], p1[1], {element: dragCoverNode}); + }); + return promise; + } + + function _assertAndDragEnd(msg, exp) { + layersRotateFromZero.forEach(function(q) { + _assertTransformRotate(msg, q, exp.fromZero); + }); + layersRotateFromRadialAxis.forEach(function(q) { + _assertTransformRotate(msg, q, exp.fromRadialAxis); + }); + + mouseEvent('mouseup', p1[0], p1[1], {element: dragCoverNode}); + return drag.waitForDragCoverRemoval(); + } + + Plotly.plot(gd, [{ + type: 'scatterpolar', + r: [1, 2, 3, 2, 3], + theta: ['a', 'b', 'c', 'd', 'e'] + }], { + polar: { + gridshape: 'linear', + angularaxis: {direction: 'clockwise'}, + radialaxis: {angle: 90} + }, + width: 400, + height: 400, + margin: {l: 50, t: 50, b: 50, r: 50} + }) + .then(function() { + gd.on('plotly_relayout', function(d) { + evtCnt++; + evtData = d; + }); + }) + .then(function() { + layersRotateFromZero.forEach(function(q) { + _assertTransformRotate('base', q, null); + }); + layersRotateFromRadialAxis.forEach(function(q) { + _assertTransformRotate('base', q, -90); + }); + }) + .then(function() { return _dragStart([150, 20], [30, 30]); }) + .then(function() { + return _assertAndDragEnd('rotate clockwise', { + fromZero: 7.2, + fromRadialAxis: -82.8 + }); + }) + .then(function() { + expect(evtCnt).toBe(1, '# of plotly_relayout calls'); + expect(evtData['polar.angularaxis.rotation']) + .toBeCloseTo(82.8, 1, 'polar.angularaxis.rotation event data'); + // have to rotate radial axis too here, to ensure it remains 'on scale' + expect(evtData['polar.radialaxis.angle']) + .toBeCloseTo(82.8, 1, 'polar.radialaxis.angle event data'); + }) + .catch(failTest) + .then(done); + }); + + it('should place zoombox handles at correct place on main drag', function(done) { + var dragCoverNode; + var p1; + + // d attr to array of segment [x,y] + function path2coords(path) { + if(!path.size()) return [[]]; + return path.attr('d') + .replace(/Z/g, '') + .split('M') + .filter(Boolean) + .map(function(s) { + return s.split('L') + .map(function(s) { return s.split(',').map(Number); }); + }) + .reduce(function(a, b) { return a.concat(b); }); + } + + function _dragStart(p0, dp) { + var node = d3.select('.polar > .draglayer > .maindrag').node(); + mouseEvent('mousemove', p0[0], p0[1], {element: node}); + mouseEvent('mousedown', p0[0], p0[1], {element: node}); + + var promise = drag.waitForDragCover().then(function(dcn) { + dragCoverNode = dcn; + p1 = [p0[0] + dp[0], p0[1] + dp[1]]; + mouseEvent('mousemove', p1[0], p1[1], {element: dragCoverNode}); + }); + return promise; + } + + function _assertAndDragEnd(msg, exp) { + var zl = d3.select(gd).select('g.zoomlayer'); + + expect(path2coords(zl.select('.zoombox'))) + .toBeCloseTo2DArray(exp.zoombox, 2, msg + ' - zoombox'); + expect(path2coords(zl.select('.zoombox-corners'))) + .toBeCloseTo2DArray(exp.corners, 2, msg + ' - corners'); + + mouseEvent('mouseup', p1[0], p1[1], {element: dragCoverNode}); + return drag.waitForDragCoverRemoval(); + } + + Plotly.plot(gd, [{ + type: 'scatterpolar', + r: [1, 2, 3, 2, 3], + theta: ['a', 'b', 'c', 'd', 'e'] + }], { + polar: { + gridshape: 'linear', + angularaxis: {direction: 'clockwise'} + }, + width: 400, + height: 400, + margin: {l: 50, t: 50, b: 50, r: 50} + }) + .then(function() { return _dragStart([170, 170], [220, 220]); }) + .then(function() { + _assertAndDragEnd('drag outward toward bottom right', { + zoombox: [ + [-142.658, -46.353], [-88.167, 121.352], + [88.167, 121.352], [142.658, -46.352], + [0, -150], [-142.658, -46.352], + [-142.658, -46.352], [-88.167, 121.352], + [88.167, 121.352], [142.658, -46.352], + [0, -150], [-142.658, -46.352], + [-49.261, -16.005], [-30.445, 41.904], + [30.44508691777904, 41.904], [49.261, -16.005], + [0, -51.796], [-49.261, -16.005] + ], + corners: [ + [-13.342, -39.630], [-33.567, -24.935], + [-35.918, -28.171], [-15.693, -42.866], + [-60.040, -103.905], [-80.266, -89.210], + [-82.617, -92.446], [-62.392, -107.141] + ] + }); + }) + .then(function() { + return Plotly.relayout(gd, 'polar.sector', [-90, 90]); + }) + .then(function() { return _dragStart([200, 200], [200, 230]); }) + .then(function() { + _assertAndDragEnd('half-sector, drag outward', { + zoombox: [ + [0, 121.352], [88.167, 121.352], + [142.658, -46.352], [0, -150], + [0, -150], [0, 0], + [0, 121.352], [0, 121.352], + [88.167, 121.352], [142.658, -46.352], + [0, -150], [0, -150], + [0, 0], [0, 121.352], + [0, 71.329], [51.823, 71.329], + [83.852, -27.245], [0, -88.16778784387097], + [0, -88.167], [0, 0], + [0, 71.329] + ], + corners: [ + [73.602, 10.771], [65.877, 34.548], + [62.073, 33.312], [69.798, 9.535], + [121.177, 26.229], [113.452, 50.006], + [109.648, 48.770], [117.373, 24.993] + ] + }); + }) + .catch(failTest) .then(done); }); });