Skip to content

Axis constraints #1522

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 16 commits into from
Apr 3, 2017
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
24 changes: 12 additions & 12 deletions src/plot_api/plot_api.js
Original file line number Diff line number Diff line change
Expand Up @@ -2079,6 +2079,18 @@ function _relayout(gd, aobj) {
else if(proot.indexOf('geo') === 0) flags.doplot = true;
else if(proot.indexOf('ternary') === 0) flags.doplot = true;
else if(ai === 'paper_bgcolor') flags.doplot = true;
else if(proot === 'margin' ||
pp1 === 'autorange' ||
pp1 === 'rangemode' ||
pp1 === 'type' ||
pp1 === 'domain' ||
pp1 === 'fixedrange' ||
pp1 === 'scaleanchor' ||
pp1 === 'scaleratio' ||
ai.indexOf('calendar') !== -1 ||
ai.match(/^(bar|box|font)/)) {
flags.docalc = true;
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

had to move this block above the following _has('gl2d') block, so scaleanchor/scaleratio get the necessary recalc. Seems like as a general rule, we might want to reorder these from biggest change to smallest, ie all the recalcs, then all the replots, then the ticks & styles... if we don't find an altogether better way to manage these flags (via the schema?).

Copy link
Contributor

Choose a reason for hiding this comment

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

I really like the idea of adding a recalc true flag to the attributes that require a recalc on restyle / relayout. But that can wait. We should do this in one PR crossing off #648.

else if(fullLayout._has('gl2d') &&
(ai.indexOf('axis') !== -1 || ai === 'plot_bgcolor')
) flags.doplot = true;
Expand All @@ -2102,18 +2114,6 @@ function _relayout(gd, aobj) {
else if(ai === 'margin.pad') {
flags.doticks = flags.dolayoutstyle = true;
}
else if(proot === 'margin' ||
pp1 === 'autorange' ||
pp1 === 'rangemode' ||
pp1 === 'type' ||
pp1 === 'domain' ||
pp1 === 'fixedrange' ||
pp1 === 'scaleanchor' ||
pp1 === 'scaleratio' ||
ai.indexOf('calendar') !== -1 ||
ai.match(/^(bar|box|font)/)) {
flags.docalc = true;
}
/*
* hovermode and dragmode don't need any redrawing, since they just
* affect reaction to user input, everything else, assume full replot.
Expand Down
121 changes: 113 additions & 8 deletions src/plots/gl2d/camera.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

var mouseChange = require('mouse-change');
var mouseWheel = require('mouse-wheel');
var cartesianConstants = require('../cartesian/constants');

module.exports = createCamera;

Expand All @@ -22,8 +23,10 @@ function Camera2D(element, plot) {
this.lastInputTime = Date.now();
this.lastPos = [0, 0];
this.boxEnabled = false;
this.boxInited = false;
this.boxStart = [0, 0];
this.boxEnd = [0, 0];
this.dragStart = [0, 0];
}


Expand All @@ -37,13 +40,33 @@ function createCamera(scene) {
scene.yaxis.autorange = false;
}

function getSubplotConstraint() {
// note: this assumes we only have one x and one y axis on this subplot
// when this constraint is lifted this block won't make sense
var constraints = scene.graphDiv._fullLayout._axisConstraintGroups;
var xaId = scene.xaxis._id;
var yaId = scene.yaxis._id;
for(var i = 0; i < constraints.length; i++) {
if(constraints[i][xaId] !== -1) {
if(constraints[i][yaId] !== -1) return true;
break;
}
}
return false;
}

result.mouseListener = mouseChange(element, function(buttons, x, y) {
var dataBox = scene.calcDataBox(),
viewBox = plot.viewBox;

var lastX = result.lastPos[0],
lastY = result.lastPos[1];

var MINDRAG = cartesianConstants.MINDRAG * plot.pixelRatio;
var MINZOOM = cartesianConstants.MINZOOM * plot.pixelRatio;

var dx, dy;

x *= plot.pixelRatio;
y *= plot.pixelRatio;

Expand Down Expand Up @@ -76,32 +99,114 @@ function createCamera(scene) {
(viewBox[3] - viewBox[1]) * (dataBox[3] - dataBox[1]) +
dataBox[1];

if(!result.boxEnabled) {
if(!result.boxInited) {
result.boxStart[0] = dataX;
result.boxStart[1] = dataY;
result.dragStart[0] = x;
result.dragStart[1] = y;
}

result.boxEnd[0] = dataX;
result.boxEnd[1] = dataY;

result.boxEnabled = true;
// we need to mark the box as initialized right away
// so that we can tell the start and end pionts apart
result.boxInited = true;

// but don't actually enable the box until the cursor moves
if(!result.boxEnabled && (
result.boxStart[0] !== result.boxEnd[0] ||
result.boxStart[1] !== result.boxEnd[1])
) {
result.boxEnabled = true;
}

// constrain aspect ratio if the axes require it
var smallDx = Math.abs(result.dragStart[0] - x) < MINZOOM;
var smallDy = Math.abs(result.dragStart[1] - y) < MINZOOM;
if(getSubplotConstraint() && !(smallDx && smallDy)) {
dx = result.boxEnd[0] - result.boxStart[0];
dy = result.boxEnd[1] - result.boxStart[1];
var dydx = (dataBox[3] - dataBox[1]) / (dataBox[2] - dataBox[0]);

if(Math.abs(dx * dydx) > Math.abs(dy)) {
result.boxEnd[1] = result.boxStart[1] +
Math.abs(dx) * dydx * (Math.sign(dy) || 1);

// gl-select-box clips to the plot area bounds,
// which breaks the axis constraint, so don't allow
// this box to go out of bounds
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

in contrast, svg lets you make these constrained boxes extend outside the plot area. Didn't seem worth diving into gl-select-box to make this marginal feature match up precisely.

Copy link
Contributor

Choose a reason for hiding this comment

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

Agreed. No need to do this for now 👍

if(result.boxEnd[1] < dataBox[1]) {
result.boxEnd[1] = dataBox[1];
result.boxEnd[0] = result.boxStart[0] +
(dataBox[1] - result.boxStart[1]) / Math.abs(dydx);
}
else if(result.boxEnd[1] > dataBox[3]) {
result.boxEnd[1] = dataBox[3];
result.boxEnd[0] = result.boxStart[0] +
(dataBox[3] - result.boxStart[1]) / Math.abs(dydx);
}
}
else {
result.boxEnd[0] = result.boxStart[0] +
Math.abs(dy) / dydx * (Math.sign(dx) || 1);

if(result.boxEnd[0] < dataBox[0]) {
result.boxEnd[0] = dataBox[0];
result.boxEnd[1] = result.boxStart[1] +
(dataBox[0] - result.boxStart[0]) * Math.abs(dydx);
}
else if(result.boxEnd[0] > dataBox[2]) {
result.boxEnd[0] = dataBox[2];
result.boxEnd[1] = result.boxStart[1] +
(dataBox[2] - result.boxStart[0]) * Math.abs(dydx);
}
}
}
// otherwise clamp small changes to the origin so we get 1D zoom
else {
if(smallDx) result.boxEnd[0] = result.boxStart[0];
if(smallDy) result.boxEnd[1] = result.boxStart[1];
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@etpinard while I was at it I added in a few of the bits of behavior used in svg, such as 1D zoom, minimum zoom size, and clamping panning to pure x (y) if dy (dx) is small enough. Do you want to take it for a spin and see what you think?

Copy link
Contributor

Choose a reason for hiding this comment

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

Loving it. Thanks!

}
}
else if(result.boxEnabled) {
updateRange(0, result.boxStart[0], result.boxEnd[0]);
updateRange(1, result.boxStart[1], result.boxEnd[1]);
unSetAutoRange();
dx = result.boxStart[0] !== result.boxEnd[0];
dy = result.boxStart[1] !== result.boxEnd[1];
if(dx || dy) {
if(dx) {
updateRange(0, result.boxStart[0], result.boxEnd[0]);
scene.xaxis.autorange = false;
}
if(dy) {
updateRange(1, result.boxStart[1], result.boxEnd[1]);
scene.yaxis.autorange = false;
}
scene.relayoutCallback();
}
else {
scene.glplot.setDirty();
}
result.boxEnabled = false;
scene.relayoutCallback();
result.boxInited = false;
}
break;

case 'pan':
result.boxEnabled = false;
result.boxInited = false;

if(buttons) {
var dx = (lastX - x) * (dataBox[2] - dataBox[0]) /
if(!result.panning) {
result.dragStart[0] = x;
result.dragStart[1] = y;
}

if(Math.abs(result.dragStart[0] - x) < MINDRAG) x = result.dragStart[0];
if(Math.abs(result.dragStart[1] - y) < MINDRAG) y = result.dragStart[1];

dx = (lastX - x) * (dataBox[2] - dataBox[0]) /
(plot.viewBox[2] - plot.viewBox[0]);
var dy = (lastY - y) * (dataBox[3] - dataBox[1]) /
dy = (lastY - y) * (dataBox[3] - dataBox[1]) /
(plot.viewBox[3] - plot.viewBox[1]);

dataBox[0] += dx;
Expand Down
39 changes: 30 additions & 9 deletions src/plots/gl2d/scene2d.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ var createOptions = require('./convert');
var createCamera = require('./camera');
var convertHTMLToUnicode = require('../../lib/html2unicode');
var showNoWebGlMsg = require('../../lib/show_no_webgl_msg');
var enforceAxisConstraints = require('../../plots/cartesian/constraints');

var AXES = ['xaxis', 'yaxis'];
var STATIC_CANVAS, STATIC_CONTEXT;
Expand Down Expand Up @@ -426,6 +427,13 @@ proto.plot = function(fullData, calcData, fullLayout) {
ax.setScale();
}

var mockLayout = {
_axisConstraintGroups: this.graphDiv._fullLayout._axisConstraintGroups,
xaxis: this.xaxis,
yaxis: this.yaxis
};
enforceAxisConstraints({_fullLayout: mockLayout});
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

kind of hacky, but enforceAxisConstraints has to happen after the Axes.doAutoRange just above, and these aren't the gd._fullLayout axes...

A bunch of things here would break if/when we support overlaid axes...


options.ticks = this.computeTickMarks();

options.dataBox = this.calcDataBox();
Expand Down Expand Up @@ -544,26 +552,36 @@ proto.draw = function() {
var x = mouseListener.x * glplot.pixelRatio;
var y = this.canvas.height - glplot.pixelRatio * mouseListener.y;

var result;

if(camera.boxEnabled && fullLayout.dragmode === 'zoom') {
this.selectBox.enabled = true;

this.selectBox.selectBox = [
var selectBox = this.selectBox.selectBox = [
Math.min(camera.boxStart[0], camera.boxEnd[0]),
Math.min(camera.boxStart[1], camera.boxEnd[1]),
Math.max(camera.boxStart[0], camera.boxEnd[0]),
Math.max(camera.boxStart[1], camera.boxEnd[1])
];

// 1D zoom
for(var i = 0; i < 2; i++) {
if(camera.boxStart[i] === camera.boxEnd[i]) {
selectBox[i] = glplot.dataBox[i];
selectBox[i + 2] = glplot.dataBox[i + 2];
}
}

glplot.setDirty();
}
else {
else if(!camera.panning) {
this.selectBox.enabled = false;

var size = fullLayout._size,
domainX = this.xaxis.domain,
domainY = this.yaxis.domain;

var result = glplot.pick(
result = glplot.pick(
(x / glplot.pixelRatio) + size.l + domainX[0] * size.w,
(y / glplot.pixelRatio) - (size.t + (1 - domainY[1]) * size.h)
);
Expand Down Expand Up @@ -629,12 +647,15 @@ proto.draw = function() {
});
}
}
else if(!result && this.lastPickResult) {
this.spikes.update({});
this.lastPickResult = null;
this.graphDiv.emit('plotly_unhover');
Fx.loneUnhover(this.svgContainer);
}
}

// Remove hover effects if we're not over a point OR
// if we're zooming or panning (in which case result is not set)
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

also to match svg

Copy link
Contributor

Choose a reason for hiding this comment

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

Very nice. Thanks 👍

if(!result && this.lastPickResult) {
this.spikes.update({});
this.lastPickResult = null;
this.graphDiv.emit('plotly_unhover');
Fx.loneUnhover(this.svgContainer);
}

glplot.draw();
Expand Down
Loading