Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 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
3262220
Refactor `calcArrayRefAutorange` as pure function
alexshoe Jan 19, 2026
296bd55
Normalize axis references in `coerceRefArray` using `cleanId`
alexshoe Jan 19, 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
66 changes: 61 additions & 5 deletions src/components/shapes/calc_autorange.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,29 @@ 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') {
const extremesForRefArray = calcArrayRefAutorange(gd, shape, 'x');
Object.entries(extremesForRefArray).forEach(([axID, axExtremes]) => {
ax = Axes.getFromId(gd, axID);
shape._extremes[ax._id] = Axes.findExtremes(ax, axExtremes, calcXPaddingOptions(shape));
});
} 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') {
const extremesForRefArray = calcArrayRefAutorange(gd, shape, 'y');
Object.entries(extremesForRefArray).forEach(([axID, axExtremes]) => {
ax = Axes.getFromId(gd, axID);
shape._extremes[ax._id] = Axes.findExtremes(ax, axExtremes, calcYPaddingOptions(shape));
});
} 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 +52,52 @@ module.exports = function calcAutorange(gd) {
}
};

function calcArrayRefAutorange(gd, shape, axLetter) {
var refs = shape[axLetter + 'ref'];
var paramsToUse = axLetter === 'x' ? constants.paramIsX : constants.paramIsY;

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[axLetter + '0']);
addToAxisGroup(refs[1], shape[axLetter + '1']);
}

// Convert coordinates to data values
var convertedGroups = {};
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);
convertedGroups[ax._id] = axisGroups[axId].map(convertVal);
}

return convertedGroups;
}

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