diff --git a/src/components/shapes/attributes.js b/src/components/shapes/attributes.js index 71a5475aee0..26a4e454b1b 100644 --- a/src/components/shapes/attributes.js +++ b/src/components/shapes/attributes.js @@ -119,12 +119,16 @@ module.exports = templatedArray('shape', { ].join(' ') }, - xref: extendFlat({}, annAttrs.xref, { + xref: { + valType: 'any', + editType: 'calc', description: [ 'Sets the shape\'s x coordinate axis.', - axisPlaceableObjs.axisRefDescription('x', 'left', 'right') + axisPlaceableObjs.axisRefDescription('x', 'left', 'right'), + 'If an array of axis IDs is provided, each `x` value will refer to the corresponding axis', + '(e.g., [\'x\', \'x2\'] for a rectangle means `x0` uses the `x` axis and `x1` uses the `x2` axis).', ].join(' ') - }), + }, xsizemode: { valType: 'enumerated', values: ['scaled', 'pixel'], @@ -193,12 +197,16 @@ module.exports = templatedArray('shape', { 'corresponds to the end of the category.' ].join(' ') }, - yref: extendFlat({}, annAttrs.yref, { + yref: { + valType: 'any', + editType: 'calc', description: [ 'Sets the shape\'s y coordinate axis.', - axisPlaceableObjs.axisRefDescription('y', 'bottom', 'top') + axisPlaceableObjs.axisRefDescription('y', 'bottom', 'top'), + 'If an array of axis IDs is provided, each `y` value will refer to the corresponding axis', + '(e.g., [\'y\', \'y2\'] for a rectangle means `y0` uses the `y` axis and `y1` uses the `y2` axis).', ].join(' ') - }), + }, ysizemode: { valType: 'enumerated', values: ['scaled', 'pixel'], diff --git a/src/components/shapes/defaults.js b/src/components/shapes/defaults.js index e725b336678..fe3b809d3c8 100644 --- a/src/components/shapes/defaults.js +++ b/src/components/shapes/defaults.js @@ -66,8 +66,7 @@ function handleShapeDefaults(shapeIn, shapeOut, fullLayout) { // positioning var axLetters = ['x', 'y']; - for(var i = 0; i < 2; i++) { - var axLetter = axLetters[i]; + axLetters.forEach(function(axLetter) { var attrAnchor = axLetter + 'anchor'; var sizeMode = axLetter === 'x' ? xSizeMode : ySizeMode; var gdMock = {_fullLayout: fullLayout}; @@ -75,9 +74,31 @@ function handleShapeDefaults(shapeIn, shapeOut, fullLayout) { var pos2r; var r2pos; - // xref, yref - var axRef = Axes.coerceRef(shapeIn, shapeOut, gdMock, axLetter, undefined, - 'paper'); + // xref, yref - handle both string and array values + var axRef; + var refAttr = axLetter + 'ref'; + var inputRef = shapeIn[refAttr]; + + if(Array.isArray(inputRef) && inputRef.length > 0) { + // Array case: use coerceRefArray for validation + var expectedLen = helpers.countDefiningCoords(shapeType, path); + axRef = Axes.coerceRefArray(shapeIn, shapeOut, gdMock, axLetter, undefined, 'paper', expectedLen); + shapeOut['_' + axLetter + 'refArray'] = true; + + // Need to register the shape with all referenced axes for redrawing purposes + axRef.forEach(function(ref) { + if(Axes.getRefType(ref) === 'range') { + ax = Axes.getFromId(gdMock, ref); + if(ax && ax._shapeIndices.indexOf(shapeOut._index) === -1) { + ax._shapeIndices.push(shapeOut._index); + } + } + }); + } else { + // String/undefined case: use coerceRef + axRef = Axes.coerceRef(shapeIn, shapeOut, gdMock, axLetter, undefined, 'paper'); + } + var axRefType = Axes.getRefType(axRef); if(axRefType === 'range') { @@ -136,7 +157,7 @@ function handleShapeDefaults(shapeIn, shapeOut, fullLayout) { shapeOut[attrAnchor] = r2pos(shapeOut[attrAnchor]); shapeIn[attrAnchor] = inAnchor; } - } + }); if(noPath) { Lib.noneOrAll(shapeIn, shapeOut, ['x0', 'x1', 'y0', 'y1']); diff --git a/src/components/shapes/helpers.js b/src/components/shapes/helpers.js index ad22a48df8c..8a29c295d7d 100644 --- a/src/components/shapes/helpers.js +++ b/src/components/shapes/helpers.js @@ -53,6 +53,25 @@ exports.extractPathCoords = function(path, paramsToUse, isRaw) { return extractedCoordinates; }; +exports.countDefiningCoords = function(shapeType, path) { + // non-path shapes always have 2 defining coordinates + if(shapeType !== 'path') return 2; + if(!path) return 0; + + var segments = path.match(constants.segmentRE); + if(!segments) return 0; + + var coordCount = 0; + segments.forEach(function(segment) { + // for each path command, check if there is a drawn coordinate + var segmentType = segment.charAt(0); + var hasDrawnX = constants.paramIsX[segmentType].drawn !== undefined; + var hasDrawnY = constants.paramIsY[segmentType].drawn !== undefined; + if(hasDrawnX || hasDrawnY) coordCount++; + }); + return coordCount; +}; + exports.getDataToPixel = function(gd, axis, shift, isVertical, refType) { var gs = gd._fullLayout._size; var dataToPixel; diff --git a/src/plots/cartesian/axes.js b/src/plots/cartesian/axes.js index bb0cead5689..dd34f353bf4 100644 --- a/src/plots/cartesian/axes.js +++ b/src/plots/cartesian/axes.js @@ -14,6 +14,7 @@ var Drawing = require('../../components/drawing'); var axAttrs = require('./layout_attributes'); var cleanTicks = require('./clean_ticks'); +var cartesianConstants = require('./constants'); var constants = require('../../constants/numerical'); var ONEMAXYEAR = constants.ONEMAXYEAR; @@ -124,6 +125,49 @@ axes.coerceRef = function(containerIn, containerOut, gd, attr, dflt, extraOption return Lib.coerce(containerIn, containerOut, attrDef, refAttr); }; +/* + * Coerce an array of axis references. Used by shapes for per-coordinate axis references. + * + * 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.coerceRefArray = function(containerIn, containerOut, gd, attr, dflt, extraOption, expectedLen) { + var axLetter = attr.charAt(attr.length - 1); + var axlist = gd._fullLayout._subplots[axLetter + 'axis']; + var refAttr = attr + 'ref'; + var axRef = containerIn[refAttr]; + + // Build the axis list, which we use to validate the axis references + if(!dflt) dflt = axlist[0] || (typeof extraOption === 'string' ? extraOption : extraOption[0]); + axlist = axlist.concat(axlist.map(function(x) { return x + ' domain'; })); + axlist = axlist.concat(extraOption ? extraOption : []); + + // Handle array length mismatch + if(axRef.length > expectedLen) { + // if the array is longer than the expected length, truncate it + Lib.warn('Array attribute ' + refAttr + ' has more entries than expected, truncating to ' + expectedLen); + axRef = axRef.slice(0, expectedLen); + } else if(axRef.length < expectedLen) { + // if the array is shorter than the expected length, extend using the default value + Lib.warn('Array attribute ' + refAttr + ' has fewer entries than expected, extending with default value'); + axRef = axRef.concat(Array(expectedLen - axRef.length).fill(dflt)); + } + + // Check all references, replace with default if invalid + for(var i = 0; i < axRef.length; i++) { + if(!axlist.includes(axRef[i])) { + axRef[i] = dflt; + } + } + + containerOut[refAttr] = axRef; + return axRef; +}; + /* * Get the type of an axis reference. This can be 'range', 'domain', or 'paper'. * This assumes ar is a valid axis reference and returns 'range' if it doesn't