Skip to content

Commit d9a8ab7

Browse files
committed
static axis scalewith/scaleratio constraints
all constraints are satisfied on initial plot, but not yet on zooms
1 parent e9a89c2 commit d9a8ab7

File tree

8 files changed

+331
-4
lines changed

8 files changed

+331
-4
lines changed

src/plot_api/plot_api.js

+5-2
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ var manageArrays = require('./manage_arrays');
3232
var helpers = require('./helpers');
3333
var subroutines = require('./subroutines');
3434
var cartesianConstants = require('../plots/cartesian/constants');
35+
var enforceAxisConstraints = require('../plots/cartesian/constraints');
3536

3637

3738
/**
@@ -256,18 +257,20 @@ Plotly.plot = function(gd, data, layout, config) {
256257
return Lib.syncOrAsync([
257258
Registry.getComponentMethod('shapes', 'calcAutorange'),
258259
Registry.getComponentMethod('annotations', 'calcAutorange'),
259-
doAutoRange,
260+
doAutoRangeAndConstraints,
260261
Registry.getComponentMethod('rangeslider', 'calcAutorange')
261262
], gd);
262263
}
263264

264-
function doAutoRange() {
265+
function doAutoRangeAndConstraints() {
265266
if(gd._transitioning) return;
266267

267268
var axList = Plotly.Axes.list(gd, '', true);
268269
for(var i = 0; i < axList.length; i++) {
269270
Plotly.Axes.doAutoRange(axList[i]);
270271
}
272+
273+
enforceAxisConstraints(gd);
271274
}
272275

273276
// draw ticks, titles, and calculate axis scaling (._b, ._m)
+128
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/**
2+
* Copyright 2012-2017, Plotly, Inc.
3+
* All rights reserved.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
10+
'use strict';
11+
12+
var Lib = require('../../lib');
13+
var id2name = require('./axis_ids').id2name;
14+
15+
16+
module.exports = function handleConstraintDefaults(containerIn, containerOut, coerce, counterAxes, layoutOut) {
17+
var constraintGroups = layoutOut._axisConstraintGroups;
18+
19+
if(!containerIn.scalewith) return;
20+
21+
var constraintOpts = getConstraintOpts(constraintGroups, containerOut._id, counterAxes, layoutOut);
22+
23+
var scalewith = Lib.coerce(containerIn, containerOut, {
24+
scalewith: {
25+
valType: 'enumerated',
26+
values: constraintOpts.linkableAxes
27+
}
28+
}, 'scalewith');
29+
30+
if(scalewith) {
31+
var scaleratio = coerce('scaleratio');
32+
// TODO: I suppose I could do attribute.min: Number.MIN_VALUE to avoid zero,
33+
// but that seems hacky. Better way to say "must be a positive number"?
34+
// Of course if you use several super-tiny values you could eventually
35+
// force a product of these to zero and all hell would break loose...
36+
// Likewise with super-huge values.
37+
if(!scaleratio) scaleratio = containerOut.scaleratio = 1;
38+
39+
updateConstraintGroups(constraintGroups, constraintOpts.thisGroup,
40+
containerOut._id, scalewith, scaleratio);
41+
}
42+
else if(counterAxes.indexOf(containerIn.scalewith) !== -1) {
43+
Lib.warn('ignored ' + containerOut._name + '.scalewith: "' +
44+
containerIn.scalewith + '" to avoid an infinite loop ' +
45+
'and possibly inconsistent scaleratios.');
46+
}
47+
};
48+
49+
function getConstraintOpts(constraintGroups, thisID, counterAxes, layoutOut) {
50+
// If this axis is already part of a constraint group, we can't
51+
// scalewith any other axis in that group, or we'd make a loop.
52+
// Filter counterAxes to enforce this, also matching axis types.
53+
54+
var thisType = layoutOut[id2name(thisID)].type;
55+
56+
var i, j, idj;
57+
for(i = 0; i < constraintGroups.length; i++) {
58+
if(constraintGroups[i][thisID]) {
59+
var thisGroup = constraintGroups[i];
60+
61+
var linkableAxes = [];
62+
for(j = 0; j < counterAxes.length; j++) {
63+
idj = counterAxes[j];
64+
if(!thisGroup[idj] && layoutOut[id2name(idj)].type === thisType) {
65+
linkableAxes.push(idj);
66+
}
67+
}
68+
return {linkableAxes: linkableAxes, thisGroup: thisGroup};
69+
}
70+
}
71+
72+
return {linkableAxes: counterAxes, thisGroup: null};
73+
}
74+
75+
76+
/*
77+
* Add this axis to the axis constraint groups, which is the collection
78+
* of axes that are all constrained together on scale.
79+
*
80+
* constraintGroups: a list of objects. each object is
81+
* {axis_id: scale_within_group}, where scale_within_group is
82+
* only important relative to the rest of the group, and defines
83+
* the relative scales between all axes in the group
84+
*
85+
* thisGroup: the group the current axis is already in
86+
* thisID: the id if the current axis
87+
* scalewith: the id of the axis to scale it with
88+
* scaleratio: the ratio of this axis to the scalewith axis
89+
*/
90+
function updateConstraintGroups(constraintGroups, thisGroup, thisID, scalewith, scaleratio) {
91+
var i, j, groupi, keyj, thisGroupIndex;
92+
93+
if(thisGroup === null) {
94+
thisGroup = {};
95+
thisGroup[thisID] = 1;
96+
thisGroupIndex = constraintGroups.length;
97+
constraintGroups.push(thisGroup);
98+
}
99+
else {
100+
thisGroupIndex = constraintGroups.indexOf(thisGroup);
101+
}
102+
103+
var thisGroupKeys = Object.keys(thisGroup);
104+
105+
// we know that this axis isn't in any other groups, but we don't know
106+
// about the scalewith axis. If it is, we need to merge the groups.
107+
for(i = 0; i < constraintGroups.length; i++) {
108+
groupi = constraintGroups[i];
109+
if(i !== thisGroupIndex && groupi[scalewith]) {
110+
var baseScale = groupi[scalewith];
111+
for(j = 0; j < thisGroupKeys.length; j++) {
112+
keyj = thisGroupKeys[j];
113+
groupi[keyj] = baseScale * scaleratio * thisGroup[keyj];
114+
}
115+
constraintGroups.splice(thisGroupIndex, 1);
116+
return;
117+
}
118+
}
119+
120+
// otherwise, we insert the new scalewith axis as the base scale (1)
121+
// in its group, and scale the rest of the group to it
122+
if(scaleratio !== 1) {
123+
for(j = 0; j < thisGroupKeys.length; j++) {
124+
thisGroup[thisGroupKeys[j]] *= scaleratio;
125+
}
126+
}
127+
thisGroup[scalewith] = 1;
128+
}

src/plots/cartesian/constraints.js

+67
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* Copyright 2012-2017, Plotly, Inc.
3+
* All rights reserved.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
10+
'use strict';
11+
12+
var id2name = require('./axis_ids').id2name;
13+
14+
var ALMOST_EQUAL = 1 - 1e-6;
15+
16+
17+
module.exports = function enforceAxisConstraints(gd) {
18+
var fullLayout = gd._fullLayout;
19+
var layout = gd.layout;
20+
var constraintGroups = fullLayout._axisConstraintGroups;
21+
22+
var i, j, axisID, ax, normScale;
23+
24+
for(i = 0; i < constraintGroups.length; i++) {
25+
var group = constraintGroups[i];
26+
var axisIDs = Object.keys(group);
27+
28+
var minScale = Infinity;
29+
var maxScale = 0;
30+
var normScales = {};
31+
var axes = {};
32+
33+
// find the (normalized) scale of each axis in the group
34+
for(j = 0; j < axisIDs.length; j++) {
35+
axisID = axisIDs[j];
36+
axes[axisID] = ax = fullLayout[id2name(axisID)];
37+
38+
// set axis scale here so we can use _m rather than
39+
// having to calculate it from length and range
40+
ax.setScale();
41+
42+
// abs: inverted scales still satisfy the constraint
43+
normScales[axisID] = normScale = Math.abs(ax._m) / group[axisID];
44+
minScale = Math.min(minScale, normScale);
45+
maxScale = Math.max(maxScale, normScale);
46+
}
47+
48+
// Do we have a constraint mismatch? Give a small buffer for rounding errors
49+
if(minScale > ALMOST_EQUAL * maxScale) continue;
50+
51+
// now increase any ranges we need to until all normalized scales are equal
52+
for(j = 0; j < axisIDs.length; j++) {
53+
axisID = axisIDs[j];
54+
normScale = normScales[axisID];
55+
if(normScale > minScale) {
56+
ax = axes[axisID];
57+
var rangeLinear = [ax.r2l(ax.range[0]), ax.r2l(ax.range[1])];
58+
var center = (rangeLinear[0] + rangeLinear[1]) / 2;
59+
var newHalfSpan = (center - rangeLinear[0]) * normScale / minScale;
60+
ax.range = layout[id2name(axisID)].range = [
61+
ax.l2r(center - newHalfSpan),
62+
ax.l2r(center + newHalfSpan)
63+
];
64+
}
65+
}
66+
}
67+
};

src/plots/cartesian/layout_attributes.js

+37-2
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,6 @@ module.exports = {
9898
'number from zero in the order it appears.'
9999
].join(' ')
100100
},
101-
102101
fixedrange: {
103102
valType: 'boolean',
104103
dflt: false,
@@ -108,6 +107,42 @@ module.exports = {
108107
'If true, then zoom is disabled.'
109108
].join(' ')
110109
},
110+
// scalewith: not used directly, just put here for reference
111+
// values are any opposite-letter axis id
112+
scalewith: {
113+
valType: 'enumerated',
114+
values: [
115+
constants.idRegex.x.toString(),
116+
constants.idRegex.y.toString()
117+
],
118+
role: 'info',
119+
description: [
120+
'If set to an opposite-letter axis id (e.g. `x2`, `y`), the range of this axis',
121+
'changes together with the range of the corresponding opposite-letter axis.',
122+
'such that the scale of pixels per unit is in a constant ratio.',
123+
'Both axes are still zoomable, but when you zoom one, the other will',
124+
'zoom the same amount, keeping a fixed midpoint.',
125+
'Autorange will also expand about the midpoints to satisfy the constraint.',
126+
'You can chain these, ie `yaxis: {scalewith: *x*}, xaxis2: {scalewith: *y*}`',
127+
'but you can only link axes of the same `type`.',
128+
'Loops (`yaxis: {scalewith: *x*}, xaxis: {scalewith: *y*}` or longer) are redundant',
129+
'and the last constraint encountered will be ignored to avoid possible',
130+
'inconsistent constraints via `scaleratio`.'
131+
].join(' ')
132+
},
133+
scaleratio: {
134+
valType: 'number',
135+
min: 0,
136+
dflt: 1,
137+
role: 'info',
138+
description: [
139+
'If this axis is linked to another by `scalewith`, this determines the pixel',
140+
'to unit scale ratio. For example, if this value is 10, then every unit on',
141+
'this axis spans 10 times the number of pixels as a unit on the linked axis.',
142+
'Use this for example to create an elevation profile where the vertical scale',
143+
'is exaggerated a fixed amount with respect to the horizontal.'
144+
].join(' ')
145+
},
111146
// ticks
112147
tickmode: {
113148
valType: 'enumerated',
@@ -430,7 +465,7 @@ module.exports = {
430465
],
431466
role: 'info',
432467
description: [
433-
'If set to an opposite-letter axis id (e.g. `xaxis2`, `yaxis`), this axis is bound to',
468+
'If set to an opposite-letter axis id (e.g. `x2`, `y`), this axis is bound to',
434469
'the corresponding opposite-letter axis.',
435470
'If set to *free*, this axis\' position is determined by `position`.'
436471
].join(' ')

src/plots/cartesian/layout_defaults.js

+3
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ var constants = require('./constants');
1818
var layoutAttributes = require('./layout_attributes');
1919
var handleTypeDefaults = require('./type_defaults');
2020
var handleAxisDefaults = require('./axis_defaults');
21+
var handleConstraintDefaults = require('./constraint_defaults');
2122
var handlePositionDefaults = require('./position_defaults');
2223
var axisIds = require('./axis_ids');
2324

@@ -184,6 +185,8 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) {
184185

185186
handleAxisDefaults(axLayoutIn, axLayoutOut, coerce, defaultOptions, layoutOut);
186187

188+
handleConstraintDefaults(axLayoutIn, axLayoutOut, coerce, counterAxes, layoutOut);
189+
187190
var positioningOptions = {
188191
letter: axLetter,
189192
counterAxes: counterAxes,
24.6 KB
Loading

test/image/mocks/axes_scalewith.json

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"data":[
3+
{"x": [0,1,1,0,0,1,1,2,2,3,3,2,2,3], "y": [0,0,1,1,3,3,2,2,3,3,1,1,0,0]},
4+
{"x": [0,1,2,3], "y": [1,2,4,8], "yaxis":"y2"}
5+
],
6+
"layout":{
7+
"width": 600,
8+
"height":600,
9+
"title": "fixed-ratio axes",
10+
"xaxis": {"nticks": 10, "title": "shared X axis"},
11+
"yaxis": {"scalewith": "x", "domain": [0, 0.45], "title": "1:1"},
12+
"yaxis2": {"scalewith": "x", "scaleratio": 0.2, "domain": [0.55,1], "title": "1:5"},
13+
"showlegend": false
14+
}
15+
}

0 commit comments

Comments
 (0)