Skip to content

Commit 01a6cc4

Browse files
authored
Merge pull request #7945 from plotly/update-quiver-api
Update quiver trace API
2 parents 58f2a0b + 74a7200 commit 01a6cc4

24 files changed

Lines changed: 179 additions & 154 deletions

draftlogs/7710_add.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
- Add `quiver` trace type to visualize vector fields using arrows [[#7710](https://github.com/plotly/plotly.js/pull/7710)], with thanks to @degzhaus for the contribution!
1+
- Add `quiver` trace type to visualize vector fields using arrows [[#7710](https://github.com/plotly/plotly.js/pull/7710), [#7945](https://github.com/plotly/plotly.js/issues/7945)], with thanks to @degzhaus for the contribution!

src/traces/quiver/attributes.js

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,62 +14,63 @@ var attrs = {
1414
valType: 'data_array',
1515
editType: 'calc+clearAxisTypes',
1616
anim: true,
17-
description: 'Sets the x coordinates of the arrow locations.'
17+
description: 'Sets the x coordinates of the vector arrow locations.'
1818
},
1919
x0: scatterAttrs.x0,
2020
dx: scatterAttrs.dx,
2121
y: {
2222
valType: 'data_array',
2323
editType: 'calc+clearAxisTypes',
2424
anim: true,
25-
description: 'Sets the y coordinates of the arrow locations.'
25+
description: 'Sets the y coordinates of the vector arrow locations.'
2626
},
2727
y0: scatterAttrs.y0,
2828
dy: scatterAttrs.dy,
2929
u: {
3030
valType: 'data_array',
3131
editType: 'calc',
3232
anim: true,
33-
description: 'Sets the x components of the arrow vectors.'
33+
description: 'Sets the x components of the vector arrows.'
3434
},
3535
v: {
3636
valType: 'data_array',
3737
editType: 'calc',
3838
anim: true,
39-
description: 'Sets the y components of the arrow vectors.'
39+
description: 'Sets the y components of the vector arrows.'
4040
},
41-
anglemode: {
41+
arrowref: {
4242
valType: 'enumerated',
4343
values: ['paper', 'data'],
44-
dflt: 'axis',
44+
dflt: 'data',
4545
editType: 'calc',
4646
description: [
47-
'Sets the mode used to determine the angle of the arrow vectors.',
47+
'Determines how the u/v vector components are interpreted.',
4848
'If *paper*, u/v are interpreted in pixel coordinates and the rendered vector angle',
49-
'does not change regardless of the axes scales.',
49+
'does not change regardless of the axis scales.',
5050
'If *data*, u/v are interpreted in data coordinates and the rendered vector angle',
5151
'may change, e.g. if zooming in along a single axis'
5252
].join(' ')
5353
},
54-
sizemode: {
54+
lengthmode: {
5555
valType: 'enumerated',
5656
values: ['scaled', 'raw'],
5757
editType: 'calc',
5858
dflt: 'scaled',
5959
description: [
60-
'Determines whether arrows are drawn according to their raw lengths,',
61-
'or scaled based on the maximum vector length and point density. Note: When `anglemode` is *data*',
62-
'arrows are alwyas scaled and `sizemode` *raw* is ignored.',
60+
'Determines whether vector arrows are drawn according to their raw lengths,',
61+
'or scaled based on the maximum vector length and point density. Note: When `arrowref` is *paper*',
62+
'vectors are always scaled and `lengthmode` *raw* is ignored.'
6363
].join(' ')
6464
},
65-
sizeref: {
65+
lengthfactor: {
6666
valType: 'number',
6767
min: 0,
6868
editType: 'calc',
6969
dflt: 1,
7070
description: [
71-
'Adjusts the arrow size scaling. The arrow length is determined by the vector norm multiplied by `sizeref`,',
72-
'optionally normalized when `sizemode` is *scaled* (`sizeref` is applied after scaling).'
71+
'Adjusts the drawn length of the vector arrows. The arrow length is determined by',
72+
'the values of u and v, then optionally rescaled when `lengthmode` is *scaled*,',
73+
'then multiplied by `lengthfactor`.',
7374
].join(' ')
7475
},
7576
anchor: {
@@ -78,9 +79,9 @@ var attrs = {
7879
dflt: 'tail',
7980
editType: 'calc',
8081
description: [
81-
'Sets the arrows\' anchor with respect to their (x,y) positions.',
82+
'Sets the vector arrows\' anchor with respect to their (x,y) positions.',
8283
'Use *tail* to place (x,y) at the base, *tip* to place (x,y) at the head,',
83-
'or *center* to center the arrow on (x,y).'
84+
'or *center* to center the vector arrow on (x,y).'
8485
].join(' ')
8586
},
8687
xhoverformat: axisHoverFormat('x'),
@@ -107,7 +108,7 @@ var attrs = {
107108
arrowsize: extendFlat({}, annotationAttrs.arrowsize, {
108109
editType: 'calc',
109110
description: [
110-
'Sets the size of the arrow head relative to `marker.line.width`.',
111+
'Sets the size of the vector arrowhead relative to `marker.line.width`.',
111112
'A value of 1 (default) gives a head about 3x as wide as the line.'
112113
].join(' ')
113114
}),
@@ -117,7 +118,7 @@ var attrs = {
117118
min: 0,
118119
dflt: 2,
119120
editType: 'style',
120-
description: 'Sets the width (in px) of the arrow lines.'
121+
description: 'Sets the width (in px) of the vector arrow lines.'
121122
},
122123
dash: dash,
123124
editType: 'style'

src/traces/quiver/calc.js

Lines changed: 59 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@ var BADNUM = require('../../constants/numerical').BADNUM;
77
var colorscaleCalc = require('../../components/colorscale/calc');
88
var calcSelection = require('../scatter/calc_selection');
99

10-
/**
11-
* Main calculation function for quiver trace
12-
* Creates calcdata with arrow path data for each vector
13-
*/
10+
// For scaled lengthmode: Constant to multiply by the computed distance between
11+
// neighboring points, such that the arrows are _just slightly shorter_ than
12+
// that distance
13+
const SHRINK_FACTOR = 0.97;
14+
1415
module.exports = function calc(gd, trace) {
1516
// Map x/y through axes so category/date values become numeric calcdata
1617
const xa = trace._xA = Axes.getFromId(gd, trace.xaxis || 'x', 'x');
@@ -33,9 +34,7 @@ module.exports = function calc(gd, trace) {
3334
const uArr = trace.u || [];
3435
const vArr = trace.v || [];
3536

36-
const anglemode = trace.anglemode;
37-
const sizemode = trace.sizemode;
38-
const anchor = trace.anchor;
37+
const { anchor, lengthmode, arrowref } = trace;
3938
const isTip = anchor === 'tip';
4039
const isCenter = anchor === 'center';
4140

@@ -54,7 +53,7 @@ module.exports = function calc(gd, trace) {
5453
var nValid = 0;
5554

5655
// First pass: build calcdata, and keep track of the maximum and minimum vector norm in the trace,
57-
// to be used for sizemode 'scaled' (max norm only) and for magnitude-based colorscale range
56+
// to be used for lengthmode 'scaled' (max norm only) and for magnitude-based colorscale range
5857
for(var i = 0; i < len; i++) {
5958
var cdi = cd[i] = { i: i };
6059
var xValid = isNumeric(xVals[i]);
@@ -109,37 +108,59 @@ module.exports = function calc(gd, trace) {
109108
// Store maxNorm for use by plot step
110109
trace._maxNorm = normMax;
111110

112-
if (sizemode === 'scaled' || anglemode === 'paper') {
113-
// Ignore sizemode 'raw' if anglemode is set to 'paper': always scale
114-
115-
// Compute point density of the entire trace: Area of bounding box
116-
// divided by number of points. This is used to scale arrows in
117-
// 'scaled' sizemode.
118-
// TODO: How to handle the case where there is just one point in a trace,
119-
// or all points have the same x or y value? This will give a boxArea of 0.
120-
// For now I'm going to just normalize to a vector of unit length (1) in that case,
121-
// but that's not a great solution
122-
const boxArea = (xMax - xMin) * (yMax - yMin);
123-
const pointDensity = boxArea / len;
124-
// Now, compute the scale factor for scaled size mode
125-
// The scale factor should be such that
126-
// _maxNorm * _scaleFactor = Math.sqrt(_pointDensity)
127-
// Therefore: _scaleFactor = Math.sqrt(_pointDensity) / _maxNorm
128-
if (pointDensity === 0) {
129-
trace._scaleFactor = 1 / trace._maxNorm
111+
// Ignore lengthmode 'raw' if arrowref is set to 'paper': always scale
112+
if (lengthmode === 'scaled' || arrowref === 'paper') {
113+
/**
114+
* Compute the maximum arrow length we should allow, using a heuristic
115+
* to estimate the distance between neighboring points.
116+
*
117+
* Let:
118+
* - D be the distance between neighboring points (the value we want to compute)
119+
* - N be the number of points in the trace
120+
* - dX be the x-width of the bounding box of all the points
121+
* - dY be the y-width of the bounding box
122+
*
123+
* We want to satisfy this equation: D = sqrt((dX + D) * (dY + D) / N)
124+
*
125+
* This is basically the square root of the point density, with an additional
126+
* adjustment to account for the points on the edges (we add D to each dimension
127+
* of the bounding box). This equation gives us the _exact_ correct distance when
128+
* the points are arranged in a perfect grid; otherwise, it's just an estimate.
129+
*
130+
* Solving for D gives us:
131+
* D = (dX + dY + sqrt((dX - dY)^2 + 4N * dX * dY)) / (2 * (N - 1))
132+
* which is the forumla we'll use below.
133+
*
134+
* Note: this formula was derived and documented by a human ;)
135+
*/
136+
137+
const dX = xMax - xMin;
138+
const dY = yMax - yMin;
139+
var pointDist;
140+
if (dX === 0 && dY === 0) {
141+
// If all points share the same x and y value, we can't estimate pointDist.
142+
// Default to an arbitrary value of 1.
143+
pointDist = 1;
130144
} else {
131-
trace._scaleFactor = Math.sqrt(pointDensity) / trace._maxNorm;
145+
// Use the formula derived above
146+
pointDist = (dX + dY + Math.sqrt((dX - dY) * (dX - dY) + 4 * nValid * dX * dY)) / (2 * (nValid - 1));
132147
}
133-
// Note: If anglemode === 'paper', this scale factor must be
148+
pointDist *= SHRINK_FACTOR; // Adjust to slightly less than the computed distance
149+
150+
// Set the trace scale factor such that the longest vector will have
151+
// a length equal to the computed pointDist
152+
trace._scaleFactor = pointDist / trace._maxNorm;
153+
154+
// Note: If arrowref === 'paper', this scale factor must be
134155
// multiplied by Math.sqrt(xa._m * ya._m), but we can't do that quite yet
135156
// since the axis scales are not fully determined. Do it in plot step instead.
136-
} else { // sizemode === 'raw'
137-
// For raw sizemode, scale factor is always 1
157+
} else {
158+
// lengthmode === 'raw'
138159
trace._scaleFactor = 1;
139160
}
140161

141-
// Multiply scale factor by sizeref
142-
trace._scaleFactor *= trace.sizeref;
162+
// Multiply computed scale factor by lengthfactor attr
163+
trace._scaleFactor *= trace.lengthfactor;
143164

144165
// Now we need to compute the arrow geometry for axis autorange
145166
const xTipPositions = new Array(len);
@@ -148,7 +169,7 @@ module.exports = function calc(gd, trace) {
148169
const yTailPositions = new Array(len);
149170
var arrowLenX, arrowLenY;
150171
// Compute the x- and y-positions of the tip of each arrow,
151-
// assuming anglemode === 'data' (i.e. u/v are in data coordinates)
172+
// assuming arrowref === 'data' (i.e. u/v are in data coordinates)
152173
for(var i = 0; i < len; i++) {
153174
var cdi = cd[i];
154175
arrowLenX = cdi._u * trace._scaleFactor;
@@ -171,14 +192,15 @@ module.exports = function calc(gd, trace) {
171192
}
172193
}
173194

174-
if (anglemode === 'data') {
175-
// If anglemode is 'data', we can use the arrow tip positions directly to expand the axes ranges
195+
if (arrowref === 'data') {
196+
// If arrowref is 'data', we can use the arrow tip positions directly to expand the axes ranges
176197
trace._extremes[xa._id] = Axes.findExtremes(xa, xTipPositions.concat(xTailPositions), {padded: true});
177198
trace._extremes[ya._id] = Axes.findExtremes(ya, yTipPositions.concat(yTailPositions), {padded: true});
178-
} else { // anglemode === 'paper'
179-
// TODO: For now, just do the same thing as for anglemode === 'data', but this is not correct.
199+
} else { // arrowref === 'paper'
200+
// TODO: For now, just do the same thing as for arrowref === 'data', but this is not correct.
180201
// We actually need more sophisticated logic here, since this will give a bad result
181202
// if the data aspect ratio is very different from the plot aspect ratio.
203+
// See https://github.com/plotly/plotly.js/issues/7979
182204
trace._extremes[xa._id] = Axes.findExtremes(xa, xTipPositions.concat(xTailPositions), {padded: true});
183205
trace._extremes[ya._id] = Axes.findExtremes(ya, yTipPositions.concat(yTailPositions), {padded: true});
184206
}

src/traces/quiver/defaults.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,11 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout
3030
for(var j = 0; j < len; j++) traceOut.v[j] = 0;
3131
}
3232

33-
coerce('anglemode');
33+
coerce('arrowref');
3434

3535
// Sizing API inspired by cone, but not identical
36-
coerce('sizemode');
37-
coerce('sizeref');
36+
coerce('lengthmode');
37+
coerce('lengthfactor');
3838
coerce('anchor');
3939

4040
// Arrow styling

src/traces/quiver/plot.js

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -102,14 +102,10 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element, transition
102102
lineSegments.exit().remove();
103103

104104
// Use maxNorm precomputed in calc
105-
const maxNorm = trace._maxNorm || 0;
106-
const anglemode = trace.anglemode;
107-
const sizemode = trace.sizemode;
108-
const sizeref = trace.sizeref;
109-
const anchor = trace.anchor;
110-
111-
// Adjust scale factor if anglemode is 'paper'
112-
const scaleFactor = (anglemode === 'paper') ? trace._scaleFactor * Math.sqrt(Math.abs(xa._m * ya._m)) : trace._scaleFactor;
105+
const { anchor, maxNorm = 0, arrowref } = trace;
106+
107+
// Adjust scale factor if arrowref is 'paper'
108+
const scaleFactor = (arrowref === 'paper') ? trace._scaleFactor * Math.sqrt(Math.abs(xa._m * ya._m)) : trace._scaleFactor;
113109
const markerArrowsize = trace.marker.arrowsize;
114110

115111
// Update line segments
@@ -123,12 +119,12 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element, transition
123119
}
124120

125121
// Compute pixel location of vector tip, *relative to* vector base (before scaling).
126-
// If anglemode is 'paper', then u/v are interpreted in pixel coordinates, so we can use them directly.
127-
// If anglemode is 'data', then u/v are interpreted in data coordinates, so we need to convert them to pixel coordinates.
122+
// If arrowref is 'paper', then u/v are interpreted in pixel coordinates, so we can use them directly.
123+
// If arrowref is 'data', then u/v are interpreted in data coordinates, so we need to convert them to pixel coordinates.
128124
// TODO: This probably doesn't work for log axes, but let's ignore log axes for now
129125
// since I'm not sure they make sense for quiver plots anyway
130-
const pu = ((anglemode === 'paper') ? cdi._u * Math.sign(xa._m) : d3.round(xa._m * cdi._u)) * scaleFactor;
131-
const pv = ((anglemode === 'paper') ? cdi._v * Math.sign(ya._m) : d3.round(ya._m * cdi._v)) * scaleFactor;
126+
const pu = ((arrowref === 'paper') ? cdi._u * Math.sign(xa._m) : d3.round(xa._m * cdi._u)) * scaleFactor;
127+
const pv = ((arrowref === 'paper') ? cdi._v * Math.sign(ya._m) : d3.round(ya._m * cdi._v)) * scaleFactor;
132128

133129
// Compute arrow in data space
134130
// Check whether arrowsize was set explicitly in the input trace

0 commit comments

Comments
 (0)