Skip to content
Open
Show file tree
Hide file tree
Changes from 13 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
20 changes: 14 additions & 6 deletions src/components/shapes/attributes.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,12 +114,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'],
Expand Down Expand Up @@ -182,12 +186,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'],
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: 4},
// A: {1: true, 6: true},
Z: {}
},
Expand Down
173 changes: 119 additions & 54 deletions src/components/shapes/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,77 +68,142 @@ 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);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Doesn't this want to be the count just for axLetter?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah you're right about that, I forgot that V and H commands could result in mismatched lengths in xref/yref

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)) {
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) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

It looks to me like this whole loop should be skipped for paths - they don't want x0shift etc, and sizemode='pixel' doesn't make sense with an axis ref array. In fact seems like we should enforce that earlier on, and document it in the sizemode descriptions: if you have an axis reference array, sizemode can only be 'scaled'.

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

if(noPath) {
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(ax.type === 'category' || ax.type === 'multicategory') {
coerce(axLetter + '0shift');
coerce(axLetter + '1shift');
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

likewise this shouldn't be done for paths

} 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
56 changes: 48 additions & 8 deletions src/components/shapes/draw.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -196,13 +198,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) {
Expand Down
Loading