Skip to content

Commit 9cf22f1

Browse files
committed
add new attribute 'anglemode' and refactor arrow-drawing code to handle it (determines whether arrow angle is affected by data scales of axes)
1 parent a800f01 commit 9cf22f1

6 files changed

Lines changed: 183 additions & 267 deletions

File tree

src/traces/quiver/attributes.js

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -38,26 +38,38 @@ var attrs = {
3838
anim: true,
3939
description: 'Sets the y components of the arrow vectors.'
4040
},
41+
anglemode: {
42+
valType: 'enumerated',
43+
values: ['paper', 'data'],
44+
dflt: 'axis',
45+
editType: 'calc',
46+
description: [
47+
'Sets the mode used to determine the angle of the arrow vectors.',
48+
'If *paper*, u/v are interpreted in pixel coordinates and the rendered vector angle',
49+
'does not change regardless of the axes scales.',
50+
'If *data*, u/v are interpreted in data coordinates and the rendered vector angle',
51+
'may change, e.g. if zooming in along a single axis'
52+
].join(' ')
53+
},
4154
sizemode: {
4255
valType: 'enumerated',
43-
values: ['scaled', 'absolute', 'raw'],
56+
values: ['scaled', 'raw'],
4457
editType: 'calc',
4558
dflt: 'scaled',
4659
description: [
47-
'Determines whether `sizeref` is set as a *scaled* (unitless) scalar',
48-
'(normalized by the max u/v norm in the vector field), as an *absolute*',
49-
'value (in the same units as the vector field), or *raw* to use the',
50-
'raw vector lengths.'
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.',
5163
].join(' ')
5264
},
5365
sizeref: {
5466
valType: 'number',
5567
min: 0,
5668
editType: 'calc',
69+
dflt: 1,
5770
description: [
58-
'Adjusts the arrow size scaling.',
59-
'The arrow length is determined by the vector norm multiplied by `sizeref`,',
60-
'optionally normalized when `sizemode` is *scaled*.'
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).'
6173
].join(' ')
6274
},
6375
anchor: {

src/traces/quiver/calc.js

Lines changed: 114 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -13,34 +13,56 @@ var calcSelection = require('../scatter/calc_selection');
1313
*/
1414
module.exports = function calc(gd, trace) {
1515
// Map x/y through axes so category/date values become numeric calcdata
16-
var xa = trace._xA = Axes.getFromId(gd, trace.xaxis || 'x', 'x');
17-
var ya = trace._yA = Axes.getFromId(gd, trace.yaxis || 'y', 'y');
16+
const xa = trace._xA = Axes.getFromId(gd, trace.xaxis || 'x', 'x');
17+
const ya = trace._yA = Axes.getFromId(gd, trace.yaxis || 'y', 'y');
1818

19-
var xVals = xa.makeCalcdata(trace, 'x');
20-
var yVals = ya.makeCalcdata(trace, 'y');
19+
const xVals = xa.makeCalcdata(trace, 'x');
20+
const yVals = ya.makeCalcdata(trace, 'y');
2121

22-
var len = Math.min(xVals.length, yVals.length);
22+
const len = Math.min(xVals.length, yVals.length);
2323
trace._length = len;
24-
var cd = new Array(len);
24+
const cd = new Array(len);
2525

2626
var normMin = Infinity;
2727
var normMax = -Infinity;
2828
var cMin = Infinity;
2929
var cMax = -Infinity;
30-
var markerColor = (trace.marker || {}).color;
31-
var hasMarkerColorArray = Lib.isArrayOrTypedArray(markerColor);
32-
33-
var uArr = trace.u || [];
34-
var vArr = trace.v || [];
35-
36-
// First pass: build calcdata and compute maxNorm (needed for 'scaled' sizemode)
30+
const markerColor = trace.marker.color;
31+
const hasMarkerColorArray = Lib.isArrayOrTypedArray(markerColor);
32+
33+
const uArr = trace.u || [];
34+
const vArr = trace.v || [];
35+
36+
const anglemode = trace.anglemode;
37+
const sizemode = trace.sizemode;
38+
const anchor = trace.anchor;
39+
const isTip = anchor === 'tip';
40+
const isCenter = anchor === 'center';
41+
42+
// Keep track of:
43+
// - minimum and maximum x and y (for density calculation)
44+
// - number of valid (x, y) pairs (for density calculation)
45+
// - minimum and maximum u and v (for setting axis ranges)
46+
var xMin = Infinity;
47+
var xMax = -Infinity;
48+
var yMin = Infinity;
49+
var yMax = -Infinity;
50+
var uMin = Infinity;
51+
var uMax = -Infinity;
52+
var vMin = Infinity;
53+
var vMax = -Infinity;
54+
var nValid = 0;
55+
56+
// 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
3758
for(var i = 0; i < len; i++) {
3859
var cdi = cd[i] = { i: i };
3960
var xValid = isNumeric(xVals[i]);
4061
var yValid = isNumeric(yVals[i]);
4162

4263
// Sanitize u/v: If either u or v is non-numeric (bad strings, Infinity,
43-
// NaN, null, undefined) for a single point, set both to zero. Numeric strings are cast.
64+
// NaN, null, undefined) for a single point, set both to zero.
65+
// Cast numeric strings to numbers.
4466
// Store in calcdata so that the sanitized values can be reused.
4567
// Use underscore-prefixed keys because 'v' is already used by box/violin
4668
// (meaning "value") and setting it here has unintended side effects.
@@ -54,14 +76,19 @@ module.exports = function calc(gd, trace) {
5476
}
5577

5678
if(xValid && yValid) {
79+
nValid++;
5780
cdi.x = xVals[i];
5881
cdi.y = yVals[i];
5982

60-
// Only plottable points feed the ranges derived here: the vector norm
61-
// range that drives 'scaled' arrow sizing (_maxNorm) and the
62-
// magnitude-based colorscale range. A point that can't be drawn must
63-
// not stretch either range. (u/v are already finite, so norm needs no
64-
// isFinite guard.)
83+
if (xVals[i] < xMin) xMin = xVals[i];
84+
if (xVals[i] > xMax) xMax = xVals[i];
85+
if (yVals[i] < yMin) yMin = yVals[i];
86+
if (yVals[i] > yMax) yMax = yVals[i];
87+
if (ui < uMin) uMin = ui;
88+
if (ui > uMax) uMax = ui;
89+
if (vi < vMin) vMin = vi;
90+
if (vi > vMax) vMax = vi;
91+
6592
var norm = Math.sqrt(ui * ui + vi * vi);
6693
if(norm > normMax) normMax = norm;
6794
if(norm < normMin) normMin = norm;
@@ -79,105 +106,86 @@ module.exports = function calc(gd, trace) {
79106
}
80107
}
81108

82-
// Store maxNorm for use by plot.js
109+
// Store maxNorm for use by plot step
83110
trace._maxNorm = normMax;
84111

85-
// Compute arrow geometry for axis autorange.
86-
//
87-
// The v-component is drawn directly in data space, so each arrow's y-tip is
88-
// exact and we expand the y-axis here with the tip coordinates. The
89-
// u-component is stretched by scaleRatio = pxPerY / pxPerX in plot.js so
90-
// that arrows keep their on-screen angle, which makes an arrow's *horizontal
91-
// pixel extent* depend on the y-scale rather than the x-scale:
92-
// |dx_px| = pxPerY * baseLen * |unitx|
93-
// We therefore expand the x-axis with pixel padding (ppad) rather than
94-
// data-space tips (whose data width would depend on the very x-range we are
95-
// trying to compute). Since that ppad depends on the y-scale - which depends
96-
// on the combined y-extent of every quiver trace sharing the axis - the
97-
// x-axis expansion is finished in crossTraceCalc; here we stash the per-point
98-
// geometry and y-bounds it needs.
99-
var sizemode = trace.sizemode || 'scaled';
100-
var sizeref = (trace.sizeref !== undefined) ? trace.sizeref : (sizemode === 'raw' ? 1 : 0.5);
101-
var anchor = trace.anchor || 'tail';
102-
var isTip = anchor === 'tip';
103-
var isCenter = anchor === 'center';
104-
105-
var baseX = new Array(len);
106-
var tipsY = new Array(len * 2);
107-
var geomLen = new Array(len);
108-
var geomUx = new Array(len);
109-
110-
var yMin = Infinity;
111-
var yMax = -Infinity;
112-
113-
for(var k = 0; k < len; k++) {
114-
var xk = xVals[k];
115-
var yk = yVals[k];
116-
var uk = cd[k]._u;
117-
var vk = cd[k]._v;
118-
var nk = Math.sqrt(uk * uk + vk * vk);
119-
120-
var baseLen;
121-
if(sizemode === 'scaled') {
122-
baseLen = normMax ? (nk / normMax) * sizeref : 0;
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
123130
} else {
124-
baseLen = nk * sizeref;
131+
trace._scaleFactor = Math.sqrt(pointDensity) / trace._maxNorm;
125132
}
133+
// Note: If anglemode === 'paper', this scale factor must be
134+
// multiplied by Math.sqrt(xa._m * ya._m), but we can't do that quite yet
135+
// 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
138+
trace._scaleFactor = 1;
139+
}
126140

127-
var unitxk = nk ? (uk / nk) : 0;
128-
var unityk = nk ? (vk / nk) : 0;
129-
var dyk = unityk * baseLen;
130-
131-
geomLen[k] = baseLen;
132-
geomUx[k] = unitxk;
133-
baseX[k] = xk;
134-
135-
var y0, y1;
136-
if(isTip) {
137-
y1 = yk;
138-
y0 = yk - dyk;
139-
} else if(isCenter) {
140-
y0 = yk - dyk / 2;
141-
y1 = yk + dyk / 2;
142-
} else { // tail (default)
143-
y0 = yk;
144-
y1 = yk + dyk;
141+
// Multiply scale factor by sizeref
142+
trace._scaleFactor *= trace.sizeref;
143+
144+
// Now we need to compute the arrow geometry for axis autorange
145+
const xTipPositions = new Array(len);
146+
const yTipPositions = new Array(len);
147+
const xTailPositions = new Array(len);
148+
const yTailPositions = new Array(len);
149+
var arrowLenX, arrowLenY;
150+
// Compute the x- and y-positions of the tip of each arrow,
151+
// assuming anglemode === 'data' (i.e. u/v are in data coordinates)
152+
for(var i = 0; i < len; i++) {
153+
var cdi = cd[i];
154+
arrowLenX = cdi._u * trace._scaleFactor;
155+
arrowLenY = cdi._v * trace._scaleFactor;
156+
if (isTip) {
157+
xTipPositions[i] = cdi.x;
158+
yTipPositions[i] = cdi.y;
159+
xTailPositions[i] = cdi.x - arrowLenX;
160+
yTailPositions[i] = cdi.y - arrowLenY;
161+
} else if (isCenter) {
162+
xTipPositions[i] = cdi.x + arrowLenX / 2;
163+
yTipPositions[i] = cdi.y + arrowLenY / 2;
164+
xTailPositions[i] = cdi.x - arrowLenX / 2;
165+
yTailPositions[i] = cdi.y - arrowLenY / 2;
166+
} else { // tail
167+
xTipPositions[i] = cdi.x + arrowLenX;
168+
yTipPositions[i] = cdi.y + arrowLenY;
169+
xTailPositions[i] = cdi.x;
170+
yTailPositions[i] = cdi.y;
145171
}
146-
tipsY[k * 2] = y0;
147-
tipsY[k * 2 + 1] = y1;
172+
}
148173

149-
if(isNumeric(y0)) {
150-
if(y0 < yMin) yMin = y0;
151-
if(y0 > yMax) yMax = y0;
152-
}
153-
if(isNumeric(y1)) {
154-
if(y1 < yMin) yMin = y1;
155-
if(y1 > yMax) yMax = y1;
156-
}
174+
if (anglemode === 'data') {
175+
// If anglemode is 'data', we can use the arrow tip positions directly to expand the axes ranges
176+
trace._extremes[xa._id] = Axes.findExtremes(xa, xTipPositions.concat(xTailPositions), {padded: true});
177+
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.
180+
// We actually need more sophisticated logic here, since this will give a bad result
181+
// if the data aspect ratio is very different from the plot aspect ratio.
182+
trace._extremes[xa._id] = Axes.findExtremes(xa, xTipPositions.concat(xTailPositions), {padded: true});
183+
trace._extremes[ya._id] = Axes.findExtremes(ya, yTipPositions.concat(yTailPositions), {padded: true});
157184
}
158185

159186
xa._minDtick = 0;
160187
ya._minDtick = 0;
161188

162-
// y-axis: arrow tips are exact in data space.
163-
trace._extremes[ya._id] = Axes.findExtremes(ya, tipsY, {padded: true});
164-
165-
// x-axis: provisional bound from the base positions only; crossTraceCalc
166-
// replaces this with a ppad-based expansion once the combined y-scale across
167-
// all quiver traces on this axis is known.
168-
trace._extremes[xa._id] = Axes.findExtremes(xa, baseX, {padded: true});
169-
170-
// Geometry needed to finish the x-axis expansion in crossTraceCalc.
171-
trace._quiver = {
172-
baseX: baseX,
173-
geomLen: geomLen,
174-
geomUx: geomUx,
175-
isTip: isTip,
176-
isCenter: isCenter,
177-
yMin: yMin,
178-
yMax: yMax
179-
};
180-
181189
// Merge text arrays into calcdata for Drawing.textPointStyle
182190
Lib.mergeArray(trace.text, cd, 'tx');
183191
Lib.mergeArray(trace.textposition, cd, 'tp');

0 commit comments

Comments
 (0)