Skip to content

Commit 65265d7

Browse files
authored
Merge pull request #7666 from alexshoe/multi-axis-reference-shapes
Multi-axis Shapes
2 parents 7d71314 + d70b23d commit 65265d7

File tree

18 files changed

+815
-159
lines changed

18 files changed

+815
-159
lines changed

draftlogs/7666_add.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- Add support for shapes to reference multiple axes [[#7666](https://github.com/plotly/plotly.js/pull/7666)]

src/components/shapes/attributes.js

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
'use strict';
22

3-
var annAttrs = require('../annotations/attributes');
3+
var cartesianConstants = require('../../plots/cartesian/constants');
44
var fontAttrs = require('../../plots/font_attributes');
55
var scatterLineAttrs = require('../../traces/scatter/attributes').line;
66
var dash = require('../drawing/attributes').dash;
77
var extendFlat = require('../../lib/extend').extendFlat;
88
var templatedArray = require('../../plot_api/plot_template').templatedArray;
99
var axisPlaceableObjs = require('../../constants/axis_placeable_objects');
1010
var basePlotAttributes = require('../../plots/attributes');
11+
var annAttrs = require('../annotations/attributes');
1112
const { shapeTexttemplateAttrs, templatefallbackAttrs } = require('../../plots/template_attributes');
1213
var shapeLabelTexttemplateVars = require('./label_texttemplate');
1314

@@ -115,9 +116,13 @@ module.exports = templatedArray('shape', {
115116
},
116117

117118
xref: extendFlat({}, annAttrs.xref, {
119+
arrayOk: true,
118120
description: [
119121
"Sets the shape's x coordinate axis.",
120-
axisPlaceableObjs.axisRefDescription('x', 'left', 'right')
122+
axisPlaceableObjs.axisRefDescription('x', 'left', 'right'),
123+
'If an array of axis IDs is provided, each `x` value will refer to the corresponding axis,',
124+
'e.g., [\'x\', \'x2\'] for a rectangle, line, or circle means `x0` uses the `x` axis and `x1` uses the `x2` axis.',
125+
'Path shapes using an array should have one entry for each x coordinate in the string.',
121126
].join(' ')
122127
}),
123128
xsizemode: {
@@ -134,7 +139,8 @@ module.exports = templatedArray('shape', {
134139
'of data or plot fraction but `x0`, `x1` and x coordinates within `path`',
135140
'are pixels relative to `xanchor`. This way, the shape can have',
136141
'a fixed width while maintaining a position relative to data or',
137-
'plot fraction.'
142+
'plot fraction.',
143+
'Note: `xsizemode` *pixel* is not supported when `xref` is an array.'
138144
].join(' ')
139145
},
140146
xanchor: {
@@ -183,9 +189,13 @@ module.exports = templatedArray('shape', {
183189
].join(' ')
184190
},
185191
yref: extendFlat({}, annAttrs.yref, {
192+
arrayOk: true,
186193
description: [
187194
"Sets the shape's y coordinate axis.",
188-
axisPlaceableObjs.axisRefDescription('y', 'bottom', 'top')
195+
axisPlaceableObjs.axisRefDescription('y', 'bottom', 'top'),
196+
'If an array of axis IDs is provided, each `y` value will refer to the corresponding axis,',
197+
'e.g., [\'y\', \'y2\'] for a rectangle, line, or circle means `y0` uses the `y` axis and `y1` uses the `y2` axis.',
198+
'Path shapes using an array should have one entry for each y coordinate in the string.',
189199
].join(' ')
190200
}),
191201
ysizemode: {
@@ -202,7 +212,8 @@ module.exports = templatedArray('shape', {
202212
'of data or plot fraction but `y0`, `y1` and y coordinates within `path`',
203213
'are pixels relative to `yanchor`. This way, the shape can have',
204214
'a fixed height while maintaining a position relative to data or',
205-
'plot fraction.'
215+
'plot fraction.',
216+
'Note: `ysizemode` *pixel* is not supported when `yref` is an array.'
206217
].join(' ')
207218
},
208219
yanchor: {

src/components/shapes/calc_autorange.js

Lines changed: 69 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,19 +21,29 @@ module.exports = function calcAutorange(gd) {
2121
var xRefType = Axes.getRefType(shape.xref);
2222
var yRefType = Axes.getRefType(shape.yref);
2323

24-
// paper and axis domain referenced shapes don't affect autorange
25-
if(shape.xref !== 'paper' && xRefType !== 'domain') {
24+
if(xRefType === 'array') {
25+
const extremesForRefArray = calcArrayRefAutorange(gd, shape, 'x');
26+
Object.entries(extremesForRefArray).forEach(([axID, axExtremes]) => {
27+
ax = Axes.getFromId(gd, axID);
28+
shape._extremes[ax._id] = Axes.findExtremes(ax, axExtremes, calcXPaddingOptions(shape));
29+
});
30+
} else if(shape.xref !== 'paper' && xRefType !== 'domain') {
31+
// paper and axis domain referenced shapes don't affect autorange
2632
ax = Axes.getFromId(gd, shape.xref);
27-
2833
bounds = shapeBounds(ax, shape, constants.paramIsX);
2934
if(bounds) {
3035
shape._extremes[ax._id] = Axes.findExtremes(ax, bounds, calcXPaddingOptions(shape));
3136
}
3237
}
3338

34-
if(shape.yref !== 'paper' && yRefType !== 'domain') {
39+
if(yRefType === 'array') {
40+
const extremesForRefArray = calcArrayRefAutorange(gd, shape, 'y');
41+
Object.entries(extremesForRefArray).forEach(([axID, axExtremes]) => {
42+
ax = Axes.getFromId(gd, axID);
43+
shape._extremes[ax._id] = Axes.findExtremes(ax, axExtremes, calcYPaddingOptions(shape));
44+
});
45+
} else if(shape.yref !== 'paper' && yRefType !== 'domain') {
3546
ax = Axes.getFromId(gd, shape.yref);
36-
3747
bounds = shapeBounds(ax, shape, constants.paramIsY);
3848
if(bounds) {
3949
shape._extremes[ax._id] = Axes.findExtremes(ax, bounds, calcYPaddingOptions(shape));
@@ -42,6 +52,60 @@ module.exports = function calcAutorange(gd) {
4252
}
4353
};
4454

55+
/**
56+
* Calculate autorange extremes for shapes that reference multiple axes (array refs).
57+
*
58+
* @param {object} gd - main graph div object
59+
* @param {object} shape - shape object
60+
* @param {string} axLetter - 'x' or 'y' indicating which axis dimension to process
61+
* @returns {object} - mapping of axis IDs to arrays of converted coordinate values
62+
*/
63+
function calcArrayRefAutorange(gd, shape, axLetter) {
64+
const refs = shape[axLetter + 'ref'];
65+
const paramsToUse = axLetter === 'x' ? constants.paramIsX : constants.paramIsY;
66+
67+
function addToAxisGroup(ref, val) {
68+
if(ref === 'paper' || Axes.getRefType(ref) === 'domain') return;
69+
if(!axisGroups[ref]) axisGroups[ref] = [];
70+
axisGroups[ref].push(val);
71+
}
72+
73+
// group coordinates by axis reference so we can calculate the extremes for each axis
74+
const axisGroups = {};
75+
if(shape.type === 'path' && shape.path) {
76+
const segments = shape.path.match(constants.segmentRE) || [];
77+
var refIndex = 0;
78+
for(var i = 0; i < segments.length; i++) {
79+
const segment = segments[i];
80+
const command = segment.charAt(0);
81+
const drawnIndex = paramsToUse[command].drawn;
82+
83+
if(drawnIndex === undefined) continue;
84+
85+
const params = segment.slice(1).match(constants.paramRE);
86+
if(params && params.length > drawnIndex) {
87+
addToAxisGroup(refs[refIndex], params[drawnIndex]);
88+
refIndex++;
89+
}
90+
}
91+
} else {
92+
addToAxisGroup(refs[0], shape[axLetter + '0']);
93+
addToAxisGroup(refs[1], shape[axLetter + '1']);
94+
}
95+
96+
// Convert coordinates to data values
97+
const convertedGroups = {};
98+
for(const axId in axisGroups) {
99+
const ax = Axes.getFromId(gd, axId);
100+
if(!ax) continue;
101+
var convertVal = (ax.type === 'category' || ax.type === 'multicategory') ? ax.r2c : ax.d2c;
102+
if(ax.type === 'date') convertVal = helpers.decodeDate(convertVal);
103+
convertedGroups[ax._id] = axisGroups[axId].map(convertVal);
104+
}
105+
106+
return convertedGroups;
107+
}
108+
45109
function calcXPaddingOptions(shape) {
46110
return calcPaddingOptions(shape.line.width, shape.xsizemode, shape.x0, shape.x1, shape.path, false);
47111
}

src/components/shapes/constants.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ module.exports = {
3333
Q: {1: true, 3: true, drawn: 3},
3434
C: {1: true, 3: true, 5: true, drawn: 5},
3535
T: {1: true, drawn: 1},
36-
S: {1: true, 3: true, drawn: 5},
36+
S: {1: true, 3: true, drawn: 3},
3737
// A: {1: true, 6: true},
3838
Z: {}
3939
},

src/components/shapes/defaults.js

Lines changed: 115 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -68,77 +68,138 @@ function handleShapeDefaults(shapeIn, shapeOut, fullLayout) {
6868
var ySizeMode = coerce('ysizemode');
6969

7070
// positioning
71-
var axLetters = ['x', 'y'];
72-
for (var i = 0; i < 2; i++) {
73-
var axLetter = axLetters[i];
71+
const dflts = [0.25, 0.75];
72+
const pixelDflts = [0, 10];
73+
74+
['x', 'y'].forEach(axLetter => {
7475
var attrAnchor = axLetter + 'anchor';
7576
var sizeMode = axLetter === 'x' ? xSizeMode : ySizeMode;
7677
var gdMock = { _fullLayout: fullLayout };
7778
var ax;
7879
var pos2r;
7980
var r2pos;
8081

81-
// xref, yref
82-
var axRef = Axes.coerceRef(shapeIn, shapeOut, gdMock, axLetter, undefined, 'paper');
83-
var axRefType = Axes.getRefType(axRef);
84-
85-
if (axRefType === 'range') {
86-
ax = Axes.getFromId(gdMock, axRef);
87-
ax._shapeIndices.push(shapeOut._index);
88-
r2pos = helpers.rangeToShapePosition(ax);
89-
pos2r = helpers.shapePositionToRange(ax);
90-
if (ax.type === 'category' || ax.type === 'multicategory') {
91-
coerce(axLetter + '0shift');
92-
coerce(axLetter + '1shift');
93-
}
82+
// xref, yref - handle both string and array values
83+
var axRef;
84+
const refAttr = axLetter + 'ref';
85+
const inputRef = shapeIn[refAttr];
86+
87+
if(Array.isArray(inputRef) && inputRef.length > 0) {
88+
// Array case: use coerceRefArray for validation
89+
const expectedLen = helpers.countDefiningCoords(shapeType, path, axLetter);
90+
axRef = Axes.coerceRefArray(shapeIn, shapeOut, gdMock, axLetter, undefined, 'paper', expectedLen);
91+
shapeOut['_' + axLetter + 'refArray'] = true;
9492
} else {
95-
pos2r = r2pos = Lib.identity;
93+
// String/undefined case: use coerceRef
94+
axRef = Axes.coerceRef(shapeIn, shapeOut, gdMock, axLetter, undefined, 'paper');
9695
}
9796

98-
// Coerce x0, x1, y0, y1
99-
if (noPath) {
100-
var dflt0 = 0.25;
101-
var dflt1 = 0.75;
102-
103-
// hack until V3.0 when log has regular range behavior - make it look like other
104-
// ranges to send to coerce, then put it back after
105-
// this is all to give reasonable default position behavior on log axes, which is
106-
// a pretty unimportant edge case so we could just ignore this.
107-
var attr0 = axLetter + '0';
108-
var attr1 = axLetter + '1';
109-
var in0 = shapeIn[attr0];
110-
var in1 = shapeIn[attr1];
111-
shapeIn[attr0] = pos2r(shapeIn[attr0], true);
112-
shapeIn[attr1] = pos2r(shapeIn[attr1], true);
113-
114-
if (sizeMode === 'pixel') {
115-
coerce(attr0, 0);
116-
coerce(attr1, 10);
97+
if(Array.isArray(axRef)) {
98+
// Register the shape with all referenced axes for redrawing purposes
99+
axRef.forEach(function(ref) {
100+
if(Axes.getRefType(ref) === 'range') {
101+
ax = Axes.getFromId(gdMock, ref);
102+
if(ax && ax._shapeIndices.indexOf(shapeOut._index) === -1) {
103+
ax._shapeIndices.push(shapeOut._index);
104+
}
105+
}
106+
});
107+
108+
if(noPath) {
109+
[0, 1].forEach(function(i) {
110+
const ref = axRef[i];
111+
const refType = Axes.getRefType(ref);
112+
if(refType === 'range') {
113+
ax = Axes.getFromId(gdMock, ref);
114+
pos2r = helpers.shapePositionToRange(ax);
115+
r2pos = helpers.rangeToShapePosition(ax);
116+
if(ax.type === 'category' || ax.type === 'multicategory') {
117+
coerce(axLetter + i + 'shift');
118+
}
119+
} else {
120+
pos2r = r2pos = Lib.identity;
121+
}
122+
123+
const attr = axLetter + i;
124+
const inValue = shapeIn[attr];
125+
shapeIn[attr] = pos2r(shapeIn[attr], true);
126+
127+
if(sizeMode === 'pixel') {
128+
coerce(attr, pixelDflts[i]);
129+
} else {
130+
Axes.coercePosition(shapeOut, gdMock, coerce, ref, attr, dflts[i]);
131+
}
132+
133+
shapeOut[attr] = r2pos(shapeOut[attr]);
134+
shapeIn[attr] = inValue;
135+
136+
if(i === 0 && sizeMode === 'pixel') {
137+
const inAnchor = shapeIn[attrAnchor];
138+
shapeIn[attrAnchor] = pos2r(shapeIn[attrAnchor], true);
139+
Axes.coercePosition(shapeOut, gdMock, coerce, ref, attrAnchor, 0.25);
140+
shapeOut[attrAnchor] = r2pos(shapeOut[attrAnchor]);
141+
shapeIn[attrAnchor] = inAnchor;
142+
}
143+
});
144+
}
145+
} else {
146+
const axRefType = Axes.getRefType(axRef);
147+
148+
if(axRefType === 'range') {
149+
ax = Axes.getFromId(gdMock, axRef);
150+
ax._shapeIndices.push(shapeOut._index);
151+
r2pos = helpers.rangeToShapePosition(ax);
152+
pos2r = helpers.shapePositionToRange(ax);
153+
if(noPath && (ax.type === 'category' || ax.type === 'multicategory')) {
154+
coerce(axLetter + '0shift');
155+
coerce(axLetter + '1shift');
156+
}
117157
} else {
118-
Axes.coercePosition(shapeOut, gdMock, coerce, axRef, attr0, dflt0);
119-
Axes.coercePosition(shapeOut, gdMock, coerce, axRef, attr1, dflt1);
158+
pos2r = r2pos = Lib.identity;
120159
}
121160

122-
// hack part 2
123-
shapeOut[attr0] = r2pos(shapeOut[attr0]);
124-
shapeOut[attr1] = r2pos(shapeOut[attr1]);
125-
shapeIn[attr0] = in0;
126-
shapeIn[attr1] = in1;
127-
}
161+
// Coerce x0, x1, y0, y1
162+
if(noPath) {
163+
// hack until V3.0 when log has regular range behavior - make it look like other
164+
// ranges to send to coerce, then put it back after
165+
// this is all to give reasonable default position behavior on log axes, which is
166+
// a pretty unimportant edge case so we could just ignore this.
167+
const attr0 = axLetter + '0';
168+
const attr1 = axLetter + '1';
169+
const in0 = shapeIn[attr0];
170+
const in1 = shapeIn[attr1];
171+
shapeIn[attr0] = pos2r(shapeIn[attr0], true);
172+
shapeIn[attr1] = pos2r(shapeIn[attr1], true);
173+
174+
if(sizeMode === 'pixel') {
175+
coerce(attr0, pixelDflts[0]);
176+
coerce(attr1, pixelDflts[1]);
177+
} else {
178+
Axes.coercePosition(shapeOut, gdMock, coerce, axRef, attr0, dflts[0]);
179+
Axes.coercePosition(shapeOut, gdMock, coerce, axRef, attr1, dflts[1]);
180+
}
181+
182+
// hack part 2
183+
shapeOut[attr0] = r2pos(shapeOut[attr0]);
184+
shapeOut[attr1] = r2pos(shapeOut[attr1]);
185+
shapeIn[attr0] = in0;
186+
shapeIn[attr1] = in1;
187+
}
128188

129-
// Coerce xanchor and yanchor
130-
if (sizeMode === 'pixel') {
131-
// Hack for log axis described above
132-
var inAnchor = shapeIn[attrAnchor];
133-
shapeIn[attrAnchor] = pos2r(shapeIn[attrAnchor], true);
189+
// Coerce xanchor and yanchor
190+
if(sizeMode === 'pixel') {
191+
// Hack for log axis described above
192+
const inAnchor = shapeIn[attrAnchor];
193+
shapeIn[attrAnchor] = pos2r(shapeIn[attrAnchor], true);
134194

135-
Axes.coercePosition(shapeOut, gdMock, coerce, axRef, attrAnchor, 0.25);
195+
Axes.coercePosition(shapeOut, gdMock, coerce, axRef, attrAnchor, 0.25);
136196

137-
// Hack part 2
138-
shapeOut[attrAnchor] = r2pos(shapeOut[attrAnchor]);
139-
shapeIn[attrAnchor] = inAnchor;
197+
// Hack part 2
198+
shapeOut[attrAnchor] = r2pos(shapeOut[attrAnchor]);
199+
shapeIn[attrAnchor] = inAnchor;
200+
}
140201
}
141-
}
202+
});
142203

143204
if (noPath) {
144205
Lib.noneOrAll(shapeIn, shapeOut, ['x0', 'x1', 'y0', 'y1']);

0 commit comments

Comments
 (0)