Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
c8be45f
Extend `xref` and `yref` attributes
alexshoe Dec 2, 2025
a7b3bb2
Add function to help validate number of defining shape vertices
alexshoe Dec 3, 2025
ace820b
Update shape defaults to handle an array of references
alexshoe Dec 4, 2025
a910d42
Modify shape xref/yref coercion logic
alexshoe Dec 5, 2025
4d70fd3
Refactor coercion logic
alexshoe Dec 9, 2025
d3208e9
Implement coordinate value coercion for array refs
alexshoe Dec 17, 2025
e5a71ad
Refactor clip path calculation for multi-axis shapes
alexshoe Dec 19, 2025
4a571aa
Merge branch 'master' into multi-axis-reference-shapes
alexshoe Dec 19, 2025
5f7eedf
Refactor autorange calculation for multi-axis shapes
alexshoe Dec 22, 2025
777cded
update plot-schema diff
alexshoe Dec 27, 2025
0a5093f
Add image test and generated baseline for multi-axis shapes
alexshoe Dec 31, 2025
de1e72c
Add Jasmine tests for multi-axis shapes
alexshoe Jan 1, 2026
e089cdf
Upload correct image baselines from CI run
alexshoe Jan 5, 2026
be959e9
Update src/components/shapes/constants.js
alexshoe Jan 6, 2026
ef94099
Merge branch 'plotly:master' into multi-axis-reference-shapes
alexshoe Jan 6, 2026
28016cf
Set arrayOk for xref/yref and update plot-schema diff
alexshoe Jan 6, 2026
13c2866
Restore enumerated values for xref/yref
alexshoe Jan 6, 2026
4cbfb3f
update plot-schema diff
alexshoe Jan 6, 2026
81aab22
Restore `extendFlat` call for xref and yref
alexshoe Jan 8, 2026
8489819
Add more detail to attribute descriptions
alexshoe Jan 15, 2026
b2a246f
Count defining coordinates by axis
alexshoe Jan 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions src/components/shapes/attributes.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
'use strict';

var annAttrs = require('../annotations/attributes');
var cartesianConstants = require('../../plots/cartesian/constants');
var fontAttrs = require('../../plots/font_attributes');
var scatterLineAttrs = require('../../traces/scatter/attributes').line;
var dash = require('../drawing/attributes').dash;
var extendFlat = require('../../lib/extend').extendFlat;
var templatedArray = require('../../plot_api/plot_template').templatedArray;
var axisPlaceableObjs = require('../../constants/axis_placeable_objects');
var basePlotAttributes = require('../../plots/attributes');
var annAttrs = require('../annotations/attributes');
const { shapeTexttemplateAttrs, templatefallbackAttrs } = require('../../plots/template_attributes');
var shapeLabelTexttemplateVars = require('./label_texttemplate');

Expand Down Expand Up @@ -115,9 +116,13 @@ module.exports = templatedArray('shape', {
},

xref: extendFlat({}, annAttrs.xref, {
arrayOk: true,
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, line, or circle means `x0` uses the `x` axis and `x1` uses the `x2` axis.',
'Path shapes using an array should have one entry for each x coordinate in the string.',
].join(' ')
}),
xsizemode: {
Expand All @@ -134,7 +139,8 @@ module.exports = templatedArray('shape', {
'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.'
'plot fraction.',
'Note: *pixel* mode is not supported when `xref` is an array.'
].join(' ')
},
xanchor: {
Expand Down Expand Up @@ -183,9 +189,13 @@ module.exports = templatedArray('shape', {
].join(' ')
},
yref: extendFlat({}, annAttrs.yref, {
arrayOk: true,
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, line, or circle means `y0` uses the `y` axis and `y1` uses the `y2` axis.',
'Path shapes using an array should have one entry for each y coordinate in the string.',
].join(' ')
}),
ysizemode: {
Expand All @@ -202,7 +212,8 @@ module.exports = templatedArray('shape', {
'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.'
'plot fraction.',
'Note: *pixel* mode is not supported when `yref` is an array.'
].join(' ')
},
yanchor: {
Expand Down
56 changes: 51 additions & 5 deletions src/components/shapes/calc_autorange.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +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
if(shape.xref !== 'paper' && xRefType !== 'domain') {
if(xRefType === 'array') {
calcArrayRefAutorange(gd, shape, 'x');
Copy link
Contributor

@emilykl emilykl Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to refactor calcArrayRefAutorange() as a pure function which doesn't modify the provided objects, but instead returns values which are then assigned/used in subsequent lines?

That would make it more transparent what is happening here, and would also make this if case more parallel with the following else if.

I realize it might not be possible without making this code pretty awkward, but could you consider it?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be more explicit, I'm picturing something along the lines of

const extremesForRefArray = calcArrayRefAutorange(gd, shape, 'x');
Object.entries(extremesForRefArray).forEach(([axID, axExtremes]) => {
  shape._extremes[ax._id] = Axes.findExtremes(ax, axExtremes.map(convertVal), paddingOpts);
});

} 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));
}
}

if(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));
Expand All @@ -42,6 +44,50 @@ module.exports = function calcAutorange(gd) {
}
};

function calcArrayRefAutorange(gd, shape, dim) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

call the third argument axLetter perhaps?

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);
}
Expand Down
2 changes: 1 addition & 1 deletion src/components/shapes/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: 3},
// A: {1: true, 6: true},
Z: {}
},
Expand Down
172 changes: 118 additions & 54 deletions src/components/shapes/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,77 +68,141 @@ function handleShapeDefaults(shapeIn, shapeOut, fullLayout) {
var ySizeMode = coerce('ysizemode');

// positioning
var axLetters = ['x', 'y'];
for (var i = 0; i < 2; i++) {
var axLetter = axLetters[i];
['x', 'y'].forEach(axLetter => {
var attrAnchor = axLetter + 'anchor';
var sizeMode = axLetter === 'x' ? xSizeMode : ySizeMode;
var gdMock = { _fullLayout: fullLayout };
var ax;
var pos2r;
var r2pos;

// xref, yref
var 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');
}
// 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, axLetter);
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 {
pos2r = r2pos = Lib.identity;
// String/undefined case: use coerceRef
axRef = Axes.coerceRef(shapeIn, shapeOut, gdMock, axLetter, undefined, 'paper');
}

// 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);
if(Array.isArray(axRef)) {
if(noPath) {
var dflts = [0.25, 0.75];
var pixelDflts = [0, 10];

[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;
}

var attr = axLetter + i;
var inValue = shapeIn[attr];
shapeIn[attr] = pos2r(shapeIn[attr], true);

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(noPath && (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);
}

// hack part 2
shapeOut[attr0] = r2pos(shapeOut[attr0]);
shapeOut[attr1] = r2pos(shapeOut[attr1]);
shapeIn[attr0] = in0;
shapeIn[attr1] = in1;
}

// Coerce xanchor and yanchor
if (sizeMode === 'pixel') {
// Hack for log axis described above
var inAnchor = shapeIn[attrAnchor];
shapeIn[attrAnchor] = pos2r(shapeIn[attrAnchor], true);
// Coerce xanchor and yanchor
if(sizeMode === 'pixel') {
// Hack for log axis described above
var inAnchor = shapeIn[attrAnchor];
shapeIn[attrAnchor] = pos2r(shapeIn[attrAnchor], true);

Axes.coercePosition(shapeOut, gdMock, coerce, axRef, attrAnchor, 0.25);
Axes.coercePosition(shapeOut, gdMock, coerce, axRef, attrAnchor, 0.25);

// Hack part 2
shapeOut[attrAnchor] = r2pos(shapeOut[attrAnchor]);
shapeIn[attrAnchor] = inAnchor;
// Hack part 2
shapeOut[attrAnchor] = r2pos(shapeOut[attrAnchor]);
shapeIn[attrAnchor] = inAnchor;
}
}
}
});

if (noPath) {
Lib.noneOrAll(shapeIn, shapeOut, ['x0', 'x1', 'y0', 'y1']);
Expand Down
Loading