Skip to content
Draft
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');
} 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) {
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);
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) {
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');
}
} 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