From c8be45fa59cf43745d99764c9d15223c0aefd4b9 Mon Sep 17 00:00:00 2001 From: Alex Hsu Date: Tue, 2 Dec 2025 14:02:23 -0500 Subject: [PATCH 01/11] Extend `xref` and `yref` attributes --- src/components/shapes/attributes.js | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) 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'], From a7b3bb25ad16fcb52ab7fc5703c0728ac3002350 Mon Sep 17 00:00:00 2001 From: Alex Hsu Date: Wed, 3 Dec 2025 11:30:16 -0500 Subject: [PATCH 02/11] Add function to help validate number of defining shape vertices --- src/components/shapes/helpers.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/components/shapes/helpers.js b/src/components/shapes/helpers.js index ad22a48df8c..e9cd40ef868 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(path, isNotPath) { + // non-path shapes always have 2 defining coordinates + if(isNotPath) 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; From ace820b9396cb0e065ee5bd2b3c344c50c34bc98 Mon Sep 17 00:00:00 2001 From: Alex Hsu Date: Thu, 4 Dec 2025 16:51:36 -0500 Subject: [PATCH 03/11] Update shape defaults to handle an array of references --- src/components/shapes/defaults.js | 33 ++++++++++++++++++++----- src/plots/cartesian/axes.js | 40 +++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 6 deletions(-) diff --git a/src/components/shapes/defaults.js b/src/components/shapes/defaults.js index e725b336678..667404425d5 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(path, noPath); + axRef = Axes.coerceRefArray(shapeIn, shapeOut, gdMock, axLetter, 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/plots/cartesian/axes.js b/src/plots/cartesian/axes.js index bb0cead5689..67a02247470 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,44 @@ 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, expectedLen) { + var axLetter = attr.charAt(attr.length - 1); + var axlist = gd._fullLayout._subplots[axLetter + 'axis']; + axlist = axlist.concat(axlist.map(function(x) { return x + ' domain'; })); + var refAttr = attr + 'ref'; + var axRef = containerIn[refAttr]; + var dflt = axlist.length ? axlist[0] : 'paper'; + + // Handle array length mismatch + if(axRef.length > expectedLen) { + // if the array is longer than the expected length, truncate it + axRef = axRef.slice(0, expectedLen); + } else if(axRef.length < expectedLen) { + // if the array is shorter than the expected length, extend using the 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(!(axRef[i] === 'paper' || cartesianConstants.idRegex[axLetter].test(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 @@ -134,6 +173,7 @@ axes.coerceRef = function(containerIn, containerOut, gd, attr, dflt, extraOption */ axes.getRefType = function(ar) { if(ar === undefined) { return ar; } + if(Array.isArray(ar)) { return 'array'; } if(ar === 'paper') { return 'paper'; } if(ar === 'pixel') { return 'pixel'; } if(/( domain)$/.test(ar)) { return 'domain'; } else { return 'range'; } From a910d42f0091679bd27cbac1b305a0477bc83f09 Mon Sep 17 00:00:00 2001 From: Alex Hsu Date: Fri, 5 Dec 2025 16:18:30 -0500 Subject: [PATCH 04/11] Modify shape xref/yref coercion logic --- src/components/shapes/defaults.js | 4 ++-- src/components/shapes/helpers.js | 4 ++-- src/plots/cartesian/axes.js | 14 +++++++++----- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/components/shapes/defaults.js b/src/components/shapes/defaults.js index 667404425d5..fe3b809d3c8 100644 --- a/src/components/shapes/defaults.js +++ b/src/components/shapes/defaults.js @@ -81,8 +81,8 @@ function handleShapeDefaults(shapeIn, shapeOut, fullLayout) { if(Array.isArray(inputRef) && inputRef.length > 0) { // Array case: use coerceRefArray for validation - var expectedLen = helpers.countDefiningCoords(path, noPath); - axRef = Axes.coerceRefArray(shapeIn, shapeOut, gdMock, axLetter, expectedLen); + 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 diff --git a/src/components/shapes/helpers.js b/src/components/shapes/helpers.js index e9cd40ef868..8a29c295d7d 100644 --- a/src/components/shapes/helpers.js +++ b/src/components/shapes/helpers.js @@ -53,9 +53,9 @@ exports.extractPathCoords = function(path, paramsToUse, isRaw) { return extractedCoordinates; }; -exports.countDefiningCoords = function(path, isNotPath) { +exports.countDefiningCoords = function(shapeType, path) { // non-path shapes always have 2 defining coordinates - if(isNotPath) return 2; + if(shapeType !== 'path') return 2; if(!path) return 0; var segments = path.match(constants.segmentRE); diff --git a/src/plots/cartesian/axes.js b/src/plots/cartesian/axes.js index 67a02247470..dd34f353bf4 100644 --- a/src/plots/cartesian/axes.js +++ b/src/plots/cartesian/axes.js @@ -135,26 +135,31 @@ axes.coerceRef = function(containerIn, containerOut, gd, attr, dflt, extraOption * 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, expectedLen) { +axes.coerceRefArray = function(containerIn, containerOut, gd, attr, dflt, extraOption, expectedLen) { var axLetter = attr.charAt(attr.length - 1); var axlist = gd._fullLayout._subplots[axLetter + 'axis']; - axlist = axlist.concat(axlist.map(function(x) { return x + ' domain'; })); var refAttr = attr + 'ref'; var axRef = containerIn[refAttr]; - var dflt = axlist.length ? axlist[0] : 'paper'; + + // 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(!(axRef[i] === 'paper' || cartesianConstants.idRegex[axLetter].test(axRef[i]))) { + if(!axlist.includes(axRef[i])) { axRef[i] = dflt; } } @@ -173,7 +178,6 @@ axes.coerceRefArray = function(containerIn, containerOut, gd, attr, expectedLen) */ axes.getRefType = function(ar) { if(ar === undefined) { return ar; } - if(Array.isArray(ar)) { return 'array'; } if(ar === 'paper') { return 'paper'; } if(ar === 'pixel') { return 'pixel'; } if(/( domain)$/.test(ar)) { return 'domain'; } else { return 'range'; } From 4d70fd34ca14254d1918fadbe0aebe25bc1a64ce Mon Sep 17 00:00:00 2001 From: Alex Hsu Date: Tue, 9 Dec 2025 12:09:13 -0500 Subject: [PATCH 05/11] Refactor coercion logic --- src/components/shapes/defaults.js | 3 +-- src/components/shapes/helpers.js | 8 +++----- src/plots/cartesian/axes.js | 6 +++--- 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/components/shapes/defaults.js b/src/components/shapes/defaults.js index fe3b809d3c8..5fcfa7ab2d9 100644 --- a/src/components/shapes/defaults.js +++ b/src/components/shapes/defaults.js @@ -65,8 +65,7 @@ function handleShapeDefaults(shapeIn, shapeOut, fullLayout) { var ySizeMode = coerce('ysizemode'); // positioning - var axLetters = ['x', 'y']; - axLetters.forEach(function(axLetter) { + ['x', 'y'].forEach(axLetter => { var attrAnchor = axLetter + 'anchor'; var sizeMode = axLetter === 'x' ? xSizeMode : ySizeMode; var gdMock = {_fullLayout: fullLayout}; diff --git a/src/components/shapes/helpers.js b/src/components/shapes/helpers.js index 8a29c295d7d..3e6aad63e7e 100644 --- a/src/components/shapes/helpers.js +++ b/src/components/shapes/helpers.js @@ -61,15 +61,13 @@ exports.countDefiningCoords = function(shapeType, path) { var segments = path.match(constants.segmentRE); if(!segments) return 0; - var coordCount = 0; - segments.forEach(function(segment) { + return segments.reduce((coordCount, 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; + return coordCount + (hasDrawnX || hasDrawnY ? 1 : 0); + }, 0); }; exports.getDataToPixel = function(gd, axis, shift, isVertical, refType) { diff --git a/src/plots/cartesian/axes.js b/src/plots/cartesian/axes.js index dd34f353bf4..1fed0f9f331 100644 --- a/src/plots/cartesian/axes.js +++ b/src/plots/cartesian/axes.js @@ -98,8 +98,7 @@ function expandRange(range) { * 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` + * extraOption: fallback value, only required if it's different from `dflt` */ axes.coerceRef = function(containerIn, containerOut, gd, attr, dflt, extraOption) { var axLetter = attr.charAt(attr.length - 1); @@ -143,7 +142,7 @@ axes.coerceRefArray = function(containerIn, containerOut, gd, attr, dflt, extraO // 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(axlist.map(x => x + ' domain')); axlist = axlist.concat(extraOption ? extraOption : []); // Handle array length mismatch @@ -178,6 +177,7 @@ axes.coerceRefArray = function(containerIn, containerOut, gd, attr, dflt, extraO */ axes.getRefType = function(ar) { if(ar === undefined) { return ar; } + if(Array.isArray(ar)) { return 'array'; } if(ar === 'paper') { return 'paper'; } if(ar === 'pixel') { return 'pixel'; } if(/( domain)$/.test(ar)) { return 'domain'; } else { return 'range'; } From d3208e91bf56039187befffe7005da8f65e9c423 Mon Sep 17 00:00:00 2001 From: Alex Hsu Date: Wed, 17 Dec 2025 11:59:10 -0500 Subject: [PATCH 06/11] Implement coordinate value coercion for array refs --- src/components/shapes/calc_autorange.js | 6 +- src/components/shapes/constants.js | 2 +- src/components/shapes/defaults.js | 142 +++++++++++++++-------- src/components/shapes/helpers.js | 146 ++++++++++++++++-------- src/plot_api/helpers.js | 10 +- 5 files changed, 203 insertions(+), 103 deletions(-) diff --git a/src/components/shapes/calc_autorange.js b/src/components/shapes/calc_autorange.js index 17c6ce23a2f..572ba79df3c 100644 --- a/src/components/shapes/calc_autorange.js +++ b/src/components/shapes/calc_autorange.js @@ -22,7 +22,8 @@ module.exports = function calcAutorange(gd) { var yRefType = Axes.getRefType(shape.yref); // paper and axis domain referenced shapes don't affect autorange - if(shape.xref !== 'paper' && xRefType !== 'domain') { + // TODO: implement autorange calculation for array ref shapes + if(xRefType !== 'array' && shape.xref !== 'paper' && xRefType !== 'domain') { ax = Axes.getFromId(gd, shape.xref); bounds = shapeBounds(ax, shape, constants.paramIsX); @@ -31,7 +32,8 @@ module.exports = function calcAutorange(gd) { } } - if(shape.yref !== 'paper' && yRefType !== 'domain') { + // TODO: implement autorange calculation for array ref shapes + if(yRefType !== 'array' && shape.yref !== 'paper' && yRefType !== 'domain') { ax = Axes.getFromId(gd, shape.yref); bounds = shapeBounds(ax, shape, constants.paramIsY); diff --git a/src/components/shapes/constants.js b/src/components/shapes/constants.js index 5a9deb44470..d0df42c18af 100644 --- a/src/components/shapes/constants.js +++ b/src/components/shapes/constants.js @@ -33,7 +33,7 @@ module.exports = { Q: {1: true, 3: true, drawn: 3}, C: {1: true, 3: true, 5: true, drawn: 5}, T: {1: true, drawn: 1}, - S: {1: true, 3: true, drawn: 5}, + S: {1: true, 3: true, drawn: 4}, // A: {1: true, 6: true}, Z: {} }, diff --git a/src/components/shapes/defaults.js b/src/components/shapes/defaults.js index 5fcfa7ab2d9..8246345463f 100644 --- a/src/components/shapes/defaults.js +++ b/src/components/shapes/defaults.js @@ -98,63 +98,107 @@ function handleShapeDefaults(shapeIn, shapeOut, fullLayout) { axRef = Axes.coerceRef(shapeIn, shapeOut, gdMock, axLetter, undefined, 'paper'); } - var axRefType = Axes.getRefType(axRef); - - if(axRefType === 'range') { - ax = Axes.getFromId(gdMock, axRef); - ax._shapeIndices.push(shapeOut._index); - r2pos = helpers.rangeToShapePosition(ax); - pos2r = helpers.shapePositionToRange(ax); - if(ax.type === 'category' || ax.type === 'multicategory') { - coerce(axLetter + '0shift'); - coerce(axLetter + '1shift'); - } - } else { - pos2r = r2pos = Lib.identity; - } + if(Array.isArray(axRef)) { + var dflts = [0.25, 0.75]; + var pixelDflts = [0, 10]; + + // For each coordinate, coerce the position with their respective axis ref + [0, 1].forEach(function(i) { + var ref = axRef[i]; + var refType = Axes.getRefType(ref); + if(refType === 'range') { + ax = Axes.getFromId(gdMock, ref); + pos2r = helpers.shapePositionToRange(ax); + r2pos = helpers.rangeToShapePosition(ax); + if(ax.type === 'category' || ax.type === 'multicategory') { + coerce(axLetter + i + 'shift'); + } + } else { + pos2r = r2pos = Lib.identity; + } - // Coerce x0, x1, y0, y1 - if(noPath) { - var dflt0 = 0.25; - var dflt1 = 0.75; - - // hack until V3.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'; - var attr1 = axLetter + '1'; - var in0 = shapeIn[attr0]; - var in1 = shapeIn[attr1]; - shapeIn[attr0] = pos2r(shapeIn[attr0], true); - shapeIn[attr1] = pos2r(shapeIn[attr1], true); + if(noPath) { + var attr = axLetter + i; + var inValue = shapeIn[attr]; + shapeIn[attr] = pos2r(shapeIn[attr], true); - if(sizeMode === 'pixel') { - coerce(attr0, 0); - coerce(attr1, 10); + if(sizeMode === 'pixel') { + coerce(attr, pixelDflts[i]); + } else { + Axes.coercePosition(shapeOut, gdMock, coerce, ref, attr, dflts[i]); + } + + shapeOut[attr] = r2pos(shapeOut[attr]); + shapeIn[attr] = inValue; + } + + if(i === 0 && sizeMode === 'pixel') { + var inAnchor = shapeIn[attrAnchor]; + shapeIn[attrAnchor] = pos2r(shapeIn[attrAnchor], true); + Axes.coercePosition(shapeOut, gdMock, coerce, ref, attrAnchor, 0.25); + shapeOut[attrAnchor] = r2pos(shapeOut[attrAnchor]); + shapeIn[attrAnchor] = inAnchor; + } + }); + } else { + var axRefType = Axes.getRefType(axRef); + + if(axRefType === 'range') { + ax = Axes.getFromId(gdMock, axRef); + ax._shapeIndices.push(shapeOut._index); + r2pos = helpers.rangeToShapePosition(ax); + pos2r = helpers.shapePositionToRange(ax); + if(ax.type === 'category' || ax.type === 'multicategory') { + coerce(axLetter + '0shift'); + coerce(axLetter + '1shift'); + } } else { - Axes.coercePosition(shapeOut, gdMock, coerce, axRef, attr0, dflt0); - Axes.coercePosition(shapeOut, gdMock, coerce, axRef, attr1, dflt1); + pos2r = r2pos = Lib.identity; } - // hack part 2 - shapeOut[attr0] = r2pos(shapeOut[attr0]); - shapeOut[attr1] = r2pos(shapeOut[attr1]); - shapeIn[attr0] = in0; - shapeIn[attr1] = in1; - } + // Coerce x0, x1, y0, y1 + if(noPath) { + var dflt0 = 0.25; + var dflt1 = 0.75; + + // hack until V3.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'; + var attr1 = axLetter + '1'; + var in0 = shapeIn[attr0]; + var in1 = shapeIn[attr1]; + shapeIn[attr0] = pos2r(shapeIn[attr0], true); + shapeIn[attr1] = pos2r(shapeIn[attr1], true); + + if(sizeMode === 'pixel') { + coerce(attr0, 0); + coerce(attr1, 10); + } else { + Axes.coercePosition(shapeOut, gdMock, coerce, axRef, attr0, dflt0); + Axes.coercePosition(shapeOut, gdMock, coerce, axRef, attr1, dflt1); + } - // Coerce xanchor and yanchor - if(sizeMode === 'pixel') { - // Hack for log axis described above - var inAnchor = shapeIn[attrAnchor]; - shapeIn[attrAnchor] = pos2r(shapeIn[attrAnchor], true); + // hack part 2 + shapeOut[attr0] = r2pos(shapeOut[attr0]); + shapeOut[attr1] = r2pos(shapeOut[attr1]); + shapeIn[attr0] = in0; + shapeIn[attr1] = in1; + } - Axes.coercePosition(shapeOut, gdMock, coerce, axRef, attrAnchor, 0.25); + // Coerce xanchor and yanchor + if(sizeMode === 'pixel') { + // Hack for log axis described above + var inAnchor = shapeIn[attrAnchor]; + shapeIn[attrAnchor] = pos2r(shapeIn[attrAnchor], true); - // Hack part 2 - shapeOut[attrAnchor] = r2pos(shapeOut[attrAnchor]); - shapeIn[attrAnchor] = inAnchor; + Axes.coercePosition(shapeOut, gdMock, coerce, axRef, attrAnchor, 0.25); + + // Hack part 2 + shapeOut[attrAnchor] = r2pos(shapeOut[attrAnchor]); + shapeIn[attrAnchor] = inAnchor; + } } }); diff --git a/src/components/shapes/helpers.js b/src/components/shapes/helpers.js index 3e6aad63e7e..b6f669ac747 100644 --- a/src/components/shapes/helpers.js +++ b/src/components/shapes/helpers.js @@ -190,66 +190,104 @@ exports.makeSelectionsOptionsAndPlotinfo = function(gd, index) { exports.getPathString = function(gd, options) { - var type = options.type; + var shapeType = options.type; var xRefType = Axes.getRefType(options.xref); var yRefType = Axes.getRefType(options.yref); - var xa = Axes.getFromId(gd, options.xref); - var ya = Axes.getFromId(gd, options.yref); var gs = gd._fullLayout._size; - var x2r, x2p, y2r, y2p; - var xShiftStart = getPixelShift(xa, options.x0shift); - var xShiftEnd = getPixelShift(xa, options.x1shift); - var yShiftStart = getPixelShift(ya, options.y0shift); - var yShiftEnd = getPixelShift(ya, options.y1shift); + var xa, ya; + var xShiftStart, xShiftEnd, yShiftStart, yShiftEnd; + var x2p, y2p; var x0, x1, y0, y1; - if(xa) { - if(xRefType === 'domain') { - x2p = function(v) { return xa._offset + xa._length * v; }; + function getConverter(axis, refType, shapeType, isVertical) { + var converter; + if(axis) { + if(refType === 'domain') { + if(isVertical) { + converter = function(v) { return axis._offset + axis._length * (1 - v); }; + } else { + converter = function(v) { return axis._offset + axis._length * v; }; + } + } else { + var d2r = exports.shapePositionToRange(axis); + converter = function(v) { return axis._offset + axis.r2p(d2r(v, true)); }; + + if(shapeType === 'path' && axis.type === 'date') converter = exports.decodeDate(converter); + } } else { - x2r = exports.shapePositionToRange(xa); - x2p = function(v) { return xa._offset + xa.r2p(x2r(v, true)); }; + if(isVertical) { + converter = function(v) { return gs.t + gs.h * (1 - v); }; + } else { + converter = function(v) { return gs.l + gs.w * v; }; + } } - } else { - x2p = function(v) { return gs.l + gs.w * v; }; - } - if(ya) { - if(yRefType === 'domain') { - y2p = function(v) { return ya._offset + ya._length * (1 - v); }; - } else { - y2r = exports.shapePositionToRange(ya); - y2p = function(v) { return ya._offset + ya.r2p(y2r(v, true)); }; - } - } else { - y2p = function(v) { return gs.t + gs.h * (1 - v); }; + return converter; } - if(type === 'path') { - if(xa && xa.type === 'date') x2p = exports.decodeDate(x2p); - if(ya && ya.type === 'date') y2p = exports.decodeDate(y2p); - return convertPath(options, x2p, y2p); + // Build function(s) to convert data to pixel + if(xRefType === 'array') { + x2p = []; + xa = options.xref.map(function(ref) { return Axes.getFromId(gd, ref); }); + x2p = options.xref.map(function(ref, i) { + return getConverter(xa[i], Axes.getRefType(ref), shapeType, false); + }); + } else { + xa = Axes.getFromId(gd, options.xref); + x2p = getConverter(xa, xRefType, shapeType, false); } - if(options.xsizemode === 'pixel') { - var xAnchorPos = x2p(options.xanchor); - x0 = xAnchorPos + options.x0 + xShiftStart; - x1 = xAnchorPos + options.x1 + xShiftEnd; + if(yRefType === 'array') { + y2p = []; + ya = options.yref.map(function(ref) { return Axes.getFromId(gd, ref); }); + y2p = options.yref.map(function(ref, i) { + return getConverter(ya[i], Axes.getRefType(ref), shapeType, true); + }); } else { - x0 = x2p(options.x0) + xShiftStart; - x1 = x2p(options.x1) + xShiftEnd; + ya = Axes.getFromId(gd, options.yref); + y2p = getConverter(ya, yRefType, shapeType, true); } - if(options.ysizemode === 'pixel') { - var yAnchorPos = y2p(options.yanchor); - y0 = yAnchorPos - options.y0 + yShiftStart; - y1 = yAnchorPos - options.y1 + yShiftEnd; + if(shapeType === 'path') { return convertPath(options, x2p, y2p); } + + // Calculate pixel coordinates for non-path shapes + // Pixel sizemode for array refs is not supported for now + if(xRefType === 'array') { + xShiftStart = getPixelShift(xa[0], options.x0shift); + xShiftEnd = getPixelShift(xa[1], options.x1shift); + x0 = x2p[0](options.x0) + xShiftStart; + x1 = x2p[1](options.x1) + xShiftEnd; + } else { + xShiftStart = getPixelShift(xa, options.x0shift); + xShiftEnd = getPixelShift(xa, options.x1shift); + if(options.xsizemode === 'pixel') { + var xAnchorPos = x2p(options.xanchor); + x0 = xAnchorPos + options.x0 + xShiftStart; + x1 = xAnchorPos + options.x1 + xShiftEnd; + } else { + x0 = x2p(options.x0) + xShiftStart; + x1 = x2p(options.x1) + xShiftEnd; + } + } + if(yRefType === 'array') { + yShiftStart = getPixelShift(ya[0], options.y0shift); + yShiftEnd = getPixelShift(ya[1], options.y1shift); + y0 = y2p[0](options.y0) + yShiftStart; + y1 = y2p[1](options.y1) + yShiftEnd; } else { - y0 = y2p(options.y0) + yShiftStart; - y1 = y2p(options.y1) + yShiftEnd; + yShiftStart = getPixelShift(ya, options.y0shift); + yShiftEnd = getPixelShift(ya, options.y1shift); + if(options.ysizemode === 'pixel') { + var yAnchorPos = y2p(options.yanchor); + y0 = yAnchorPos - options.y0 + yShiftStart; + y1 = yAnchorPos - options.y1 + yShiftEnd; + } else { + y0 = y2p(options.y0) + yShiftStart; + y1 = y2p(options.y1) + yShiftEnd; + } } - if(type === 'line') return 'M' + x0 + ',' + y0 + 'L' + x1 + ',' + y1; - if(type === 'rect') return 'M' + x0 + ',' + y0 + 'H' + x1 + 'V' + y1 + 'H' + x0 + 'Z'; + if(shapeType === 'line') return 'M' + x0 + ',' + y0 + 'L' + x1 + ',' + y1; + if(shapeType === 'rect') return 'M' + x0 + ',' + y0 + 'H' + x1 + 'V' + y1 + 'H' + x0 + 'Z'; // circle var cx = (x0 + x1) / 2; @@ -263,13 +301,16 @@ exports.getPathString = function(gd, options) { rArc + ' 0 0,1 ' + rightPt + 'Z'; }; - function convertPath(options, x2p, y2p) { var pathIn = options.path; var xSizemode = options.xsizemode; var ySizemode = options.ysizemode; var xAnchor = options.xanchor; var yAnchor = options.yanchor; + var isArrayXref = Array.isArray(options.xref); + var isArrayYref = Array.isArray(options.yref); + var xVertexIndex = 0; + var yVertexIndex = 0; return pathIn.replace(constants.segmentRE, function(segment) { var paramNumber = 0; @@ -277,14 +318,20 @@ function convertPath(options, x2p, y2p) { var xParams = constants.paramIsX[segmentType]; var yParams = constants.paramIsY[segmentType]; var nParams = constants.numParams[segmentType]; + var hasDrawnX = xParams.drawn !== undefined; + var hasDrawnY = yParams.drawn !== undefined; + + // Use vertex indices for array refs (same converter for all params in segment) + var segmentX2p = (isArrayXref && xSizemode !== 'pixel') ? x2p[xVertexIndex] : x2p; + var segmentY2p = (isArrayYref && ySizemode !== 'pixel') ? y2p[yVertexIndex] : y2p; var paramString = segment.substr(1).replace(constants.paramRE, function(param) { if(xParams[paramNumber]) { - if(xSizemode === 'pixel') param = x2p(xAnchor) + Number(param); - else param = x2p(param); + if(xSizemode === 'pixel') param = segmentX2p(xAnchor) + Number(param); + else param = segmentX2p(param); } else if(yParams[paramNumber]) { - if(ySizemode === 'pixel') param = y2p(yAnchor) - Number(param); - else param = y2p(param); + if(ySizemode === 'pixel') param = segmentY2p(yAnchor) - Number(param); + else param = segmentY2p(param); } paramNumber++; @@ -297,6 +344,9 @@ function convertPath(options, x2p, y2p) { Lib.log('Ignoring extra params in segment ' + segment); } + if(hasDrawnX) xVertexIndex++; + if(hasDrawnY) yVertexIndex++; + return segmentType + paramString; }); } diff --git a/src/plot_api/helpers.js b/src/plot_api/helpers.js index 15840ad11a8..0d1777a8d32 100644 --- a/src/plot_api/helpers.js +++ b/src/plot_api/helpers.js @@ -101,8 +101,8 @@ exports.cleanLayout = function (layout) { if (!Lib.isPlainObject(shape)) continue; - cleanAxRef(shape, 'xref'); - cleanAxRef(shape, 'yref'); + cleanAxRef(shape, 'xref', true); + cleanAxRef(shape, 'yref', true); } var imagesLen = Array.isArray(layout.images) ? layout.images.length : 0; @@ -152,9 +152,13 @@ exports.cleanLayout = function (layout) { return layout; }; -function cleanAxRef(container, attr) { +function cleanAxRef(container, attr, isShape = false) { var valIn = container[attr]; var axLetter = attr.charAt(0); + + // Skip for shapes with array references + if (isShape && Array.isArray(valIn)) return; + if (valIn && valIn !== 'paper') { container[attr] = cleanId(valIn, axLetter, true); } From e5a71ad8ac041371ef407eefe0ea96095e39b12c Mon Sep 17 00:00:00 2001 From: Alex Hsu Date: Fri, 19 Dec 2025 11:55:31 -0500 Subject: [PATCH 07/11] Refactor clip path calculation for multi-axis shapes --- src/components/shapes/draw.js | 50 ++++++++++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 6 deletions(-) diff --git a/src/components/shapes/draw.js b/src/components/shapes/draw.js index 8f97ee23043..664c4c32a8d 100644 --- a/src/components/shapes/draw.js +++ b/src/components/shapes/draw.js @@ -196,13 +196,51 @@ function setClipPath(shapePath, gd, shapeOptions) { // // if axis is 'paper' or an axis with " domain" appended, then there is no // clip axis - var clipAxes = (shapeOptions.xref + shapeOptions.yref).replace(/paper/g, '').replace(/[xyz][0-9]* *domain/g, ''); - Drawing.setClipUrl( - shapePath, - clipAxes ? 'clip' + gd._fullLayout._uid + clipAxes : null, - gd - ); + var xref = shapeOptions.xref; + var yref = shapeOptions.yref; + + // For multi-axis shapes, create a custom clip path from axis bounds + if(Array.isArray(xref) || Array.isArray(yref)) { + var clipId = 'clip' + gd._fullLayout._uid + 'shape' + shapeOptions._index; + var rect = getMultiAxisClipRect(gd, xref, yref); + + Lib.ensureSingleById(gd._fullLayout._clips, 'clipPath', clipId, function(s) { + s.append('rect'); + }).select('rect').attr(rect); + + Drawing.setClipUrl(shapePath, clipId, gd); + return; + } + + var clipAxes = (xref + yref).replace(/paper/g, '').replace(/[xyz][0-9]* *domain/g, ''); + Drawing.setClipUrl(shapePath, clipAxes ? 'clip' + gd._fullLayout._uid + clipAxes : null, gd); +} + +function getMultiAxisClipRect(gd, xref, yref) { + var gs = gd._fullLayout._size; + + function getBounds(refs, isVertical) { + // Retrieve all existing axes from the references + var axes = (Array.isArray(refs) ? refs : [refs]) + .map(r => Axes.getFromId(gd, r)) + .filter(Boolean); + + // If no valid axes, return the bounds of the larger plot area + if(!axes.length) { + return isVertical ? [gs.t, gs.t + gs.h] : [gs.l, gs.l + gs.w]; + } + + // Otherwise, we find all find and return the smallest start point + // and largest end point to be used as the clip bounds + var startBounds = axes.map(function(ax) { return ax._offset; }); + var endBounds = axes.map(function(ax) { return ax._offset + ax._length; }); + return [Math.min(...startBounds), Math.max(...endBounds)]; + } + + var xb = getBounds(xref, false); + var yb = getBounds(yref, true); + return {x: xb[0], y: yb[0], width: xb[1] - xb[0], height: yb[1] - yb[0]}; } function setupDragElement(gd, shapePath, shapeOptions, index, shapeLayer, editHelpers) { From 5f7eedfaa1069fdde0c3b392441ae4c9171a4890 Mon Sep 17 00:00:00 2001 From: Alex Hsu Date: Mon, 22 Dec 2025 14:08:45 -0600 Subject: [PATCH 08/11] Refactor autorange calculation for multi-axis shapes --- src/components/shapes/calc_autorange.js | 58 ++++++++++++++++++++++--- src/components/shapes/draw.js | 6 ++- 2 files changed, 55 insertions(+), 9 deletions(-) diff --git a/src/components/shapes/calc_autorange.js b/src/components/shapes/calc_autorange.js index 9d25f663014..0bb0fb92ae2 100644 --- a/src/components/shapes/calc_autorange.js +++ b/src/components/shapes/calc_autorange.js @@ -21,21 +21,21 @@ module.exports = function calcAutorange(gd) { var xRefType = Axes.getRefType(shape.xref); var yRefType = Axes.getRefType(shape.yref); - // paper and axis domain referenced shapes don't affect autorange - // TODO: implement autorange calculation for array ref shapes - if(xRefType !== 'array' && shape.xref !== 'paper' && xRefType !== 'domain') { + if(xRefType === 'array') { + calcArrayRefAutorange(gd, shape, 'x'); + } else if(shape.xref !== 'paper' && xRefType !== 'domain') { + // paper and axis domain referenced shapes don't affect autorange ax = Axes.getFromId(gd, shape.xref); - bounds = shapeBounds(ax, shape, constants.paramIsX); if(bounds) { shape._extremes[ax._id] = Axes.findExtremes(ax, bounds, calcXPaddingOptions(shape)); } } - // TODO: implement autorange calculation for array ref shapes - if(yRefType !== 'array' && shape.yref !== 'paper' && yRefType !== 'domain') { + if(yRefType === 'array') { + calcArrayRefAutorange(gd, shape, 'y'); + } else if(shape.yref !== 'paper' && yRefType !== 'domain') { ax = Axes.getFromId(gd, shape.yref); - bounds = shapeBounds(ax, shape, constants.paramIsY); if(bounds) { shape._extremes[ax._id] = Axes.findExtremes(ax, bounds, calcYPaddingOptions(shape)); @@ -44,6 +44,50 @@ module.exports = function calcAutorange(gd) { } }; +function calcArrayRefAutorange(gd, shape, dim) { + var refs = shape[dim + 'ref']; + var paramsToUse = dim === 'x' ? constants.paramIsX : constants.paramIsY; + var paddingOpts = dim === 'x' ? calcXPaddingOptions(shape) : calcYPaddingOptions(shape); + + function addToAxisGroup(ref, val) { + if(ref === 'paper' || Axes.getRefType(ref) === 'domain') return; + if(!axisGroups[ref]) axisGroups[ref] = []; + axisGroups[ref].push(val); + } + + // group coordinates by axis reference so we can calculate the extremes for each axis + var axisGroups = {}; + if(shape.type === 'path' && shape.path) { + var segments = shape.path.match(constants.segmentRE) || []; + var refIndex = 0; + for(var i = 0; i < segments.length; i++) { + var segment = segments[i]; + var command = segment.charAt(0); + var drawnIndex = paramsToUse[command].drawn; + + if(drawnIndex === undefined) continue; + + var params = segment.slice(1).match(constants.paramRE); + if(params && params.length > drawnIndex) { + addToAxisGroup(refs[refIndex], params[drawnIndex]); + refIndex++; + } + } + } else { + addToAxisGroup(refs[0], shape[dim + '0']); + addToAxisGroup(refs[1], shape[dim + '1']); + } + + // For each axis, convert coordinates to data values then calculate extremes + for(var axId in axisGroups) { + var ax = Axes.getFromId(gd, axId); + if(!ax) continue; + var convertVal = (ax.type === 'category' || ax.type === 'multicategory') ? ax.r2c : ax.d2c; + if(ax.type === 'date') convertVal = helpers.decodeDate(convertVal); + shape._extremes[ax._id] = Axes.findExtremes(ax, axisGroups[axId].map(convertVal), paddingOpts); + } +} + function calcXPaddingOptions(shape) { return calcPaddingOptions(shape.line.width, shape.xsizemode, shape.x0, shape.x1, shape.path, false); } diff --git a/src/components/shapes/draw.js b/src/components/shapes/draw.js index 481fee85bdb..2ef2558eba2 100644 --- a/src/components/shapes/draw.js +++ b/src/components/shapes/draw.js @@ -91,11 +91,13 @@ function drawOne(gd, index) { // TODO: use d3 idioms instead of deleting and redrawing every time if(!options._input || options.visible !== true) return; + var isMultiAxisShape = Array.isArray(options.xref) || Array.isArray(options.yref); + if(options.layer === 'above') { drawShape(gd._fullLayout._shapeUpperLayer); - } else if(options.xref === 'paper' || options.yref === 'paper') { + } else if(options.xref.includes('paper') || options.yref.includes('paper')) { drawShape(gd._fullLayout._shapeLowerLayer); - } else if(options.layer === 'between') { + } else if(options.layer === 'between' && !isMultiAxisShape) { drawShape(plotinfo.shapelayerBetween); } else { if(plotinfo._hadPlotinfo) { From 777cded62ba11eee28467d4306e597ca229eeb9a Mon Sep 17 00:00:00 2001 From: Alex Hsu Date: Sat, 27 Dec 2025 16:47:15 -0600 Subject: [PATCH 09/11] update plot-schema diff --- test/plot-schema.json | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/test/plot-schema.json b/test/plot-schema.json index 211da680a56..b16460ecbb6 100644 --- a/test/plot-schema.json +++ b/test/plot-schema.json @@ -9898,13 +9898,9 @@ "valType": "any" }, "xref": { - "description": "Sets the shape's x coordinate axis. If set to a x axis id (e.g. *x* or *x2*), the `x` position refers to a x coordinate. If set to *paper*, the `x` position refers to the distance from the left of the plotting area in normalized coordinates where *0* (*1*) corresponds to the left (right). If set to a x axis ID followed by *domain* (separated by a space), the position behaves like for *paper*, but refers to the distance in fractions of the domain length from the left of the domain of that axis: e.g., *x2 domain* refers to the domain of the second x axis and a x position of 0.5 refers to the point between the left and the right of the domain of the second x axis.", + "description": "Sets the shape's x coordinate axis. If set to a x axis id (e.g. *x* or *x2*), the `x` position refers to a x coordinate. If set to *paper*, the `x` position refers to the distance from the left of the plotting area in normalized coordinates where *0* (*1*) corresponds to the left (right). If set to a x axis ID followed by *domain* (separated by a space), the position behaves like for *paper*, but refers to the distance in fractions of the domain length from the left of the domain of that axis: e.g., *x2 domain* refers to the domain of the second x axis and a x position of 0.5 refers to the point between the left and the right of the domain of the second x axis. 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).", "editType": "calc", - "valType": "enumerated", - "values": [ - "paper", - "/^x([2-9]|[1-9][0-9]+)?( domain)?$/" - ] + "valType": "any" }, "xsizemode": { "description": "Sets the shapes's sizing mode along the x axis. If set to *scaled*, `x0`, `x1` and x coordinates within `path` refer to data values on the x axis or a fraction of the plot area's width (`xref` set to *paper*). If set to *pixel*, `xanchor` specifies the x position in terms of data or plot fraction but `x0`, `x1` and x coordinates within `path` are pixels relative to `xanchor`. This way, the shape can have a fixed width while maintaining a position relative to data or plot fraction.", @@ -9948,13 +9944,9 @@ "valType": "any" }, "yref": { - "description": "Sets the shape's y coordinate axis. If set to a y axis id (e.g. *y* or *y2*), the `y` position refers to a y coordinate. If set to *paper*, the `y` position refers to the distance from the bottom of the plotting area in normalized coordinates where *0* (*1*) corresponds to the bottom (top). If set to a y axis ID followed by *domain* (separated by a space), the position behaves like for *paper*, but refers to the distance in fractions of the domain length from the bottom of the domain of that axis: e.g., *y2 domain* refers to the domain of the second y axis and a y position of 0.5 refers to the point between the bottom and the top of the domain of the second y axis.", + "description": "Sets the shape's y coordinate axis. If set to a y axis id (e.g. *y* or *y2*), the `y` position refers to a y coordinate. If set to *paper*, the `y` position refers to the distance from the bottom of the plotting area in normalized coordinates where *0* (*1*) corresponds to the bottom (top). If set to a y axis ID followed by *domain* (separated by a space), the position behaves like for *paper*, but refers to the distance in fractions of the domain length from the bottom of the domain of that axis: e.g., *y2 domain* refers to the domain of the second y axis and a y position of 0.5 refers to the point between the bottom and the top of the domain of the second y axis. 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).", "editType": "calc", - "valType": "enumerated", - "values": [ - "paper", - "/^y([2-9]|[1-9][0-9]+)?( domain)?$/" - ] + "valType": "any" }, "ysizemode": { "description": "Sets the shapes's sizing mode along the y axis. If set to *scaled*, `y0`, `y1` and y coordinates within `path` refer to data values on the y axis or a fraction of the plot area's height (`yref` set to *paper*). If set to *pixel*, `yanchor` specifies the y position in terms of data or plot fraction but `y0`, `y1` and y coordinates within `path` are pixels relative to `yanchor`. This way, the shape can have a fixed height while maintaining a position relative to data or plot fraction.", From 0a5093f39c72fd176c17f7b79be24e1cea73af43 Mon Sep 17 00:00:00 2001 From: Alex Hsu Date: Wed, 31 Dec 2025 13:36:04 -0600 Subject: [PATCH 10/11] Add image test and generated baseline for multi-axis shapes --- test/image/baselines/multi_axis_shapes.png | Bin 0 -> 42177 bytes test/image/mocks/multi_axis_shapes.json | 60 +++++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 test/image/baselines/multi_axis_shapes.png create mode 100644 test/image/mocks/multi_axis_shapes.json diff --git a/test/image/baselines/multi_axis_shapes.png b/test/image/baselines/multi_axis_shapes.png new file mode 100644 index 0000000000000000000000000000000000000000..66509b71826252eec6f0278e5aa6a61768097389 GIT binary patch literal 42177 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 Date: Thu, 1 Jan 2026 12:17:54 -0600 Subject: [PATCH 11/11] Add Jasmine tests for multi-axis shapes --- test/jasmine/tests/shapes_test.js | 272 ++++++++++++++++++++++++++++++ 1 file changed, 272 insertions(+) diff --git a/test/jasmine/tests/shapes_test.js b/test/jasmine/tests/shapes_test.js index 96175e497bf..965f86873a8 100644 --- a/test/jasmine/tests/shapes_test.js +++ b/test/jasmine/tests/shapes_test.js @@ -1662,3 +1662,275 @@ describe('Test shapes', function() { return coordinates; } }); + +describe('Test multi-axis shapes', function() { + 'use strict'; + + var gd; + + beforeEach(function() { gd = createGraphDiv(); }); + afterEach(destroyGraphDiv); + + function getShape(index) { + var s = d3SelectAll('.shapelayer path[data-index="' + index + '"]'); + expect(s.size()).toBe(1); + return s; + } + + function getBoundingBox(index) { + var node = getShape(index).node(); + return node ? node.getBoundingClientRect() : null; + } + + it('renders all shape types with array xref and yref values', function(done) { + Plotly.newPlot(gd, [ + {x: [1, 2], y: [1, 2]}, + {x: [1, 2], y: [1, 2], xaxis: 'x2', yaxis: 'y2'} + ], { + xaxis: {domain: [0, 0.45]}, + yaxis: {domain: [0, 0.45]}, + xaxis2: {domain: [0.55, 1], anchor: 'y2'}, + yaxis2: {domain: [0.55, 1], anchor: 'x2'}, + shapes: [ + {type: 'line', xref: ['x', 'x2'], yref: ['y', 'y2'], x0: 1, x1: 2, y0: 1, y1: 2}, + {type: 'rect', xref: ['x', 'x2'], yref: ['y', 'y2'], x0: 1.5, x1: 1.5, y0: 1.5, y1: 1.5}, + {type: 'circle', xref: ['x', 'x2'], yref: ['y', 'y2'], x0: 1, x1: 2, y0: 1, y1: 2}, + {type: 'path', xref: ['x', 'x2'], yref: ['y', 'y2'], path: 'M1,1L2,2'} + ] + }).then(function() { + expect(getShape(0)).not.toBe(null); + expect(getShape(1)).not.toBe(null); + expect(getShape(2)).not.toBe(null); + expect(getShape(3)).not.toBe(null); + }) + .then(done, done.fail); + }); + + it('positions shapes correctly with side-by-side subplots', function(done) { + Plotly.newPlot(gd, [ + {x: [1, 2], y: [1, 2]}, + {x: [1, 2], y: [1, 2], xaxis: 'x2', yaxis: 'y2'} + ], { + width: 800, height: 400, + xaxis: {domain: [0, 0.45]}, + yaxis: {domain: [0, 0.45]}, + xaxis2: {domain: [0.55, 1], anchor: 'y2'}, + yaxis2: {domain: [0.55, 1], anchor: 'x2'}, + shapes: [ + {type: 'line', xref: ['x', 'x2'], yref: ['y', 'y2'], x0: 1, x1: 2, y0: 1, y1: 2}, + {type: 'rect', xref: ['x', 'x2'], yref: ['y', 'y2'], x0: 1.5, x1: 1.5, y0: 1.5, y1: 1.5}, + {type: 'circle', xref: ['x', 'x2'], yref: ['y', 'y2'], x0: 1, x1: 2, y0: 1, y1: 2}, + {type: 'path', xref: ['x', 'x2'], yref: ['y', 'y2'], path: 'M1,1L2,2'} + ] + }).then(function() { + var xa = gd._fullLayout.xaxis; + var xa2 = gd._fullLayout.xaxis2; + + var lineExpectedLeft = xa.l2p(1) + xa._offset; + var lineExpectedRight = xa2.l2p(2) + xa2._offset; + var lineBBox = getBoundingBox(0); + + expect(lineBBox.left).toBeCloseTo(lineExpectedLeft); + expect(lineBBox.right).toBeCloseTo(lineExpectedRight); + + var rectExpectedLeft = xa.l2p(1.5) + xa._offset; + var rectExpectedRight = xa2.l2p(1.5) + xa2._offset; + var rectBBox = getBoundingBox(1); + + expect(rectBBox.left).toBeCloseTo(rectExpectedLeft); + expect(rectBBox.right).toBeCloseTo(rectExpectedRight); + + var circleExpectedLeft = xa.l2p(1) + xa._offset; + var circleExpectedRight = xa2.l2p(2) + xa2._offset; + var circleBBox = getBoundingBox(2); + + expect(circleBBox.left).toBeCloseTo(circleExpectedLeft); + expect(circleBBox.right).toBeCloseTo(circleExpectedRight); + + var pathExpectedLeft = xa.l2p(1) + xa._offset; + var pathExpectedRight = xa2.l2p(2) + xa2._offset; + var pathBBox = getBoundingBox(3); + + expect(pathBBox.left).toBeCloseTo(pathExpectedLeft); + expect(pathBBox.right).toBeCloseTo(pathExpectedRight); + }) + .then(done, done.fail); + }); + + it('positions shapes correctly with overlaid axes', function(done) { + Plotly.newPlot(gd, [ + {x: [1, 2], y: [1, 2]}, + {x: [1, 2], y: [50, 100], yaxis: 'y2'} + ], { + width: 600, height: 400, + yaxis: {range: [0, 10]}, + yaxis2: {overlaying: 'y', side: 'right', range: [0, 100]}, + shapes: [ + {type: 'line', xref: 'x', yref: ['y', 'y2'], x0: 1, x1: 2, y0: 1, y1: 20}, + {type: 'rect', xref: 'x', yref: ['y', 'y2'], x0: 1.5, x1: 1.5, y0: 1.5, y1: 50}, + {type: 'circle', xref: 'x', yref: ['y', 'y2'], x0: 1, x1: 2, y0: 1, y1: 65}, + {type: 'path', xref: 'x', yref: ['y', 'y2'], path: 'M1,1L2,90'}] + }).then(function() { + var ya = gd._fullLayout.yaxis; + var ya2 = gd._fullLayout.yaxis2; + + var lineExpectedBottom = ya.l2p(1) + ya._offset; + var lineExpectedTop = ya2.l2p(20) + ya2._offset; + var lineBBox = getBoundingBox(0); + + expect(lineBBox.bottom).toBeCloseTo(lineExpectedBottom); + expect(lineBBox.top).toBeCloseTo(lineExpectedTop); + + var rectExpectedBottom = ya.l2p(1.5) + ya._offset; + var rectExpectedTop = ya2.l2p(50) + ya2._offset; + var rectBBox = getBoundingBox(1); + + expect(rectBBox.bottom).toBeCloseTo(rectExpectedBottom); + expect(rectBBox.top).toBeCloseTo(rectExpectedTop); + + var circleExpectedBottom = ya.l2p(1) + ya._offset; + var circleExpectedTop = ya2.l2p(65) + ya2._offset; + var circleBBox = getBoundingBox(2); + + expect(circleBBox.bottom).toBeCloseTo(circleExpectedBottom); + expect(circleBBox.top).toBeCloseTo(circleExpectedTop); + + var pathExpectedBottom = ya.l2p(1) + ya._offset; + var pathExpectedTop = ya2.l2p(90) + ya2._offset; + var pathBBox = getBoundingBox(3); + + expect(pathBBox.bottom).toBeCloseTo(pathExpectedBottom); + expect(pathBBox.top).toBeCloseTo(pathExpectedTop); + }) + .then(done, done.fail); + }); + + it('adjusts shape position when one referenced axis is zoomed', function(done) { + Plotly.newPlot(gd, [ + {x: [0, 4], y: [0, 4]}, + {x: [0, 4], y: [0, 4], xaxis: 'x2', yaxis: 'y2'} + ], { + width: 800, height: 400, + xaxis: {domain: [0, 0.45], range: [0, 4]}, + yaxis: {range: [0, 4]}, + xaxis2: {domain: [0.55, 1], anchor: 'y2', range: [0, 4]}, + yaxis2: {anchor: 'x2', range: [0, 4]}, + shapes: [ + {type: 'line', xref: ['x', 'x2'], yref: ['y', 'y2'], x0: 1, x1: 2, y0: 1, y1: 2}, + {type: 'rect', xref: ['x', 'x2'], yref: ['y', 'y2'], x0: 1.5, x1: 1.5, y0: 1.5, y1: 1.5}, + {type: 'circle', xref: ['x', 'x2'], yref: ['y', 'y2'], x0: 1, x1: 2, y0: 1, y1: 2}, + {type: 'path', xref: ['x', 'x2'], yref: ['y', 'y2'], path: 'M1,1L2,2'} + ] + }).then(function() { + return Plotly.relayout(gd, 'xaxis.range', [0, 2]); + }).then(function() { + var xa = gd._fullLayout.xaxis; + var xa2 = gd._fullLayout.xaxis2; + + var lineExpectedLeft = xa.l2p(1) + xa._offset; + var lineExpectedRight = xa2.l2p(2) + xa2._offset; + var lineBBox = getBoundingBox(0); + + expect(lineBBox.left).toBeCloseTo(lineExpectedLeft); + expect(lineBBox.right).toBeCloseTo(lineExpectedRight); + + var rectExpectedLeft = xa.l2p(1.5) + xa._offset; + var rectExpectedRight = xa2.l2p(1.5) + xa2._offset; + var rectBBox = getBoundingBox(1); + + expect(rectBBox.left).toBeCloseTo(rectExpectedLeft); + expect(rectBBox.right).toBeCloseTo(rectExpectedRight); + + var circleExpectedLeft = xa.l2p(1) + xa._offset; + var circleExpectedRight = xa2.l2p(2) + xa2._offset; + var circleBBox = getBoundingBox(2); + + expect(circleBBox.left).toBeCloseTo(circleExpectedLeft); + expect(circleBBox.right).toBeCloseTo(circleExpectedRight); + + var pathExpectedLeft = xa.l2p(1) + xa._offset; + var pathExpectedRight = xa2.l2p(2) + xa2._offset; + var pathBBox = getBoundingBox(3); + + expect(pathBBox.left).toBeCloseTo(pathExpectedLeft); + expect(pathBBox.right).toBeCloseTo(pathExpectedRight); + }) + .then(done, done.fail); + }); + + it('updates shape when panning a referenced axis', function(done) { + Plotly.newPlot(gd, [ + {x: [0, 4], y: [0, 4]}, + {x: [0, 4], y: [0, 4], xaxis: 'x2', yaxis: 'y2'} + ], { + width: 800, height: 400, + xaxis: {domain: [0, 0.45], range: [0, 4]}, + yaxis: {range: [0, 4]}, + xaxis2: {domain: [0.55, 1], anchor: 'y2', range: [0, 4]}, + yaxis2: {anchor: 'x2', range: [0, 4]}, + shapes: [ + {type: 'line', xref: ['x', 'x2'], yref: ['y', 'y2'], x0: 1, x1: 2, y0: 1, y1: 2}, + {type: 'rect', xref: ['x', 'x2'], yref: ['y', 'y2'], x0: 1.5, x1: 1.5, y0: 1.5, y1: 1.5}, + {type: 'circle', xref: ['x', 'x2'], yref: ['y', 'y2'], x0: 1, x1: 2, y0: 1, y1: 2}, + {type: 'path', xref: ['x', 'x2'], yref: ['y', 'y2'], path: 'M1,1L2,2'} + ] + }).then(function() { + return Plotly.relayout(gd, 'yaxis.range', [1, 5]); + }).then(function() { + var ya = gd._fullLayout.yaxis; + var ya2 = gd._fullLayout.yaxis2; + + var lineExpectedBottom = ya.l2p(1) + ya._offset; + var lineExpectedTop = ya2.l2p(2) + ya2._offset; + var lineBBox = getBoundingBox(0); + + expect(lineBBox.bottom).toBeCloseTo(lineExpectedBottom); + expect(lineBBox.top).toBeCloseTo(lineExpectedTop); + + var rectExpectedBottom = ya.l2p(1.5) + ya._offset; + var rectExpectedTop = ya2.l2p(1.5) + ya2._offset; + var rectBBox = getBoundingBox(1); + + expect(rectBBox.bottom).toBeCloseTo(rectExpectedBottom); + expect(rectBBox.top).toBeCloseTo(rectExpectedTop); + + var circleExpectedBottom = ya.l2p(1) + ya._offset; + var circleExpectedTop = ya2.l2p(2) + ya2._offset; + var circleBBox = getBoundingBox(2); + + expect(circleBBox.bottom).toBeCloseTo(circleExpectedBottom); + expect(circleBBox.top).toBeCloseTo(circleExpectedTop); + + var pathExpectedBottom = ya.l2p(1) + ya._offset; + var pathExpectedTop = ya2.l2p(2) + ya2._offset; + var pathBBox = getBoundingBox(3); + + expect(pathBBox.bottom).toBeCloseTo(pathExpectedBottom); + expect(pathBBox.top).toBeCloseTo(pathExpectedTop); + }) + .then(done, done.fail); + }); + + it('handles autorange', function(done) { + Plotly.newPlot(gd, [ + {x: [1, 2], y: [1, 2]}, + {x: [1, 2], y: [1, 2], xaxis: 'x2', yaxis: 'y2'} + ], { + xaxis: {domain: [0, 0.45]}, + yaxis: {domain: [0, 0.45]}, + xaxis2: {domain: [0.55, 1], anchor: 'y2'}, + yaxis2: {domain: [0.55, 1], anchor: 'x2'}, + shapes: [{ + type: 'rect', + xref: ['x', 'x2'], yref: ['y', 'y2'], + x0: 0, x1: 5, y0: 0, y1: 5 + }] + }).then(function() { + expect(gd._fullLayout.xaxis.range[0]).toBeLessThan(1); + expect(gd._fullLayout.yaxis.range[0]).toBeLessThan(1); + expect(gd._fullLayout.xaxis2.range[1]).toBeGreaterThan(4); + expect(gd._fullLayout.yaxis2.range[1]).toBeGreaterThan(4); + }) + .then(done, done.fail); + }); +});