@@ -7,10 +7,11 @@ var BADNUM = require('../../constants/numerical').BADNUM;
77var colorscaleCalc = require ( '../../components/colorscale/calc' ) ;
88var 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+
1415module . 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 }
0 commit comments