Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
2 changes: 1 addition & 1 deletion draftlogs/7710_add.md
Original file line number Diff line number Diff line change
@@ -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!
- 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!
39 changes: 20 additions & 19 deletions src/traces/quiver/attributes.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,62 +14,63 @@ var attrs = {
valType: 'data_array',
editType: 'calc+clearAxisTypes',
anim: true,
description: 'Sets the x coordinates of the arrow locations.'
description: 'Sets the x coordinates of the vector arrow locations.'
},
x0: scatterAttrs.x0,
dx: scatterAttrs.dx,
y: {
valType: 'data_array',
editType: 'calc+clearAxisTypes',
anim: true,
description: 'Sets the y coordinates of the arrow locations.'
description: 'Sets the y coordinates of the vector arrow locations.'
},
y0: scatterAttrs.y0,
dy: scatterAttrs.dy,
u: {
valType: 'data_array',
editType: 'calc',
anim: true,
description: 'Sets the x components of the arrow vectors.'
description: 'Sets the x components of the vector arrows.'
},
v: {
valType: 'data_array',
editType: 'calc',
anim: true,
description: 'Sets the y components of the arrow vectors.'
description: 'Sets the y components of the vector arrows.'
},
anglemode: {
arrowref: {
valType: 'enumerated',
values: ['paper', 'data'],
dflt: 'axis',
dflt: 'data',
editType: 'calc',
description: [
'Sets the mode used to determine the angle of the arrow vectors.',
'Determines how the u/v vector components are interpreted.',
'If *paper*, u/v are interpreted in pixel coordinates and the rendered vector angle',
'does not change regardless of the axes scales.',
'does not change regardless of the axis scales.',
'If *data*, u/v are interpreted in data coordinates and the rendered vector angle',
'may change, e.g. if zooming in along a single axis'
].join(' ')
},
sizemode: {
lengthmode: {
valType: 'enumerated',
values: ['scaled', 'raw'],
editType: 'calc',
dflt: 'scaled',
description: [
'Determines whether arrows are drawn according to their raw lengths,',
'or scaled based on the maximum vector length and point density. Note: When `anglemode` is *data*',
'arrows are alwyas scaled and `sizemode` *raw* is ignored.',
'Determines whether vector arrows are drawn according to their raw lengths,',
'or scaled based on the maximum vector length and point density. Note: When `arrowref` is *paper*',
'vectors are always scaled and `lengthmode` *raw* is ignored.'
].join(' ')
},
sizeref: {
lengthfactor: {
valType: 'number',
min: 0,
editType: 'calc',
dflt: 1,
description: [
'Adjusts the arrow size scaling. The arrow length is determined by the vector norm multiplied by `sizeref`,',
'optionally normalized when `sizemode` is *scaled* (`sizeref` is applied after scaling).'
'Adjusts the drawn length of the vector arrows. The arrow length is determined by',
'the values of u and v, then optionally rescaled when `lengthmode` is *scaled*,',
'then multiplied by `lengthfactor`.',
].join(' ')
},
anchor: {
Expand All @@ -78,9 +79,9 @@ var attrs = {
dflt: 'tail',
editType: 'calc',
description: [
'Sets the arrows\' anchor with respect to their (x,y) positions.',
'Sets the vector arrows\' anchor with respect to their (x,y) positions.',
'Use *tail* to place (x,y) at the base, *tip* to place (x,y) at the head,',
'or *center* to center the arrow on (x,y).'
'or *center* to center the vector arrow on (x,y).'
].join(' ')
},
xhoverformat: axisHoverFormat('x'),
Expand All @@ -107,7 +108,7 @@ var attrs = {
arrowsize: extendFlat({}, annotationAttrs.arrowsize, {
editType: 'calc',
description: [
'Sets the size of the arrow head relative to `marker.line.width`.',
'Sets the size of the vector arrowhead relative to `marker.line.width`.',
'A value of 1 (default) gives a head about 3x as wide as the line.'
].join(' ')
}),
Expand All @@ -117,7 +118,7 @@ var attrs = {
min: 0,
dflt: 2,
editType: 'style',
description: 'Sets the width (in px) of the arrow lines.'
description: 'Sets the width (in px) of the vector arrow lines.'
},
dash: dash,
editType: 'style'
Expand Down
96 changes: 59 additions & 37 deletions src/traces/quiver/calc.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ var BADNUM = require('../../constants/numerical').BADNUM;
var colorscaleCalc = require('../../components/colorscale/calc');
var calcSelection = require('../scatter/calc_selection');

/**
* Main calculation function for quiver trace
* Creates calcdata with arrow path data for each vector
*/
// For scaled lengthmode: Constant to multiply by the computed distance between
// neighboring points, such that the arrows are _just slightly shorter_ than
// that distance
const SHRINK_FACTOR = 0.97;
// const SHRINK_FACTOR = 1;

module.exports = function calc(gd, trace) {
// Map x/y through axes so category/date values become numeric calcdata
const xa = trace._xA = Axes.getFromId(gd, trace.xaxis || 'x', 'x');
Expand All @@ -33,9 +35,7 @@ module.exports = function calc(gd, trace) {
const uArr = trace.u || [];
const vArr = trace.v || [];

const anglemode = trace.anglemode;
const sizemode = trace.sizemode;
const anchor = trace.anchor;
const { anchor, lengthmode, arrowref } = trace;
const isTip = anchor === 'tip';
const isCenter = anchor === 'center';

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

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

if (sizemode === 'scaled' || anglemode === 'paper') {
// Ignore sizemode 'raw' if anglemode is set to 'paper': always scale

// Compute point density of the entire trace: Area of bounding box
// divided by number of points. This is used to scale arrows in
// 'scaled' sizemode.
// TODO: How to handle the case where there is just one point in a trace,
// or all points have the same x or y value? This will give a boxArea of 0.
// For now I'm going to just normalize to a vector of unit length (1) in that case,
// but that's not a great solution
const boxArea = (xMax - xMin) * (yMax - yMin);
const pointDensity = boxArea / len;
// Now, compute the scale factor for scaled size mode
// The scale factor should be such that
// _maxNorm * _scaleFactor = Math.sqrt(_pointDensity)
// Therefore: _scaleFactor = Math.sqrt(_pointDensity) / _maxNorm
if (pointDensity === 0) {
trace._scaleFactor = 1 / trace._maxNorm
// Ignore lengthmode 'raw' if arrowref is set to 'paper': always scale
if (lengthmode === 'scaled' || arrowref === 'paper') {
/**
* Compute the maximum arrow length we should allow, using a heuristic
* to estimate the distance between neighboring points.
*
* Let:
* - D be the distance between neighboring points (the value we want to compute)
* - N be the number of points in the trace
* - dX be the x-width of the bounding box of all the points
* - dY be the y-width of the bounding box
*
* We want to satisfy this equation: D = sqrt((dX + D) * (dY + D) / N)
*
* This is basically the square root of the point density, with an additional
* adjustment to account for the points on the edges (we add D to each dimension
* of the bounding box). This equation gives us the _exact_ correct distance when
* the points are arranged in a perfect grid; otherwise, it's just an estimate.
*
* Solving for D gives us:
* D = (dX + dY + sqrt((dX - dY)^2 + 4N * dX * dY)) / (2 * (N - 1))
* which is the forumla we'll use below.
*
* Note: this formula was derived and documented by a human ;)
*/

const dX = xMax - xMin;
const dY = yMax - yMin;
var pointDist;
if (dX === 0 && dY === 0) {
// If all points share the same x and y value, we can't estimate pointDist.
// Default to an arbitrary value of 1.
pointDist = 1;
} else {
trace._scaleFactor = Math.sqrt(pointDensity) / trace._maxNorm;
// Use the formula derived above
pointDist = (dX + dY + Math.sqrt((dX - dY) * (dX - dY) + 4 * nValid * dX * dY)) / (2 * (nValid - 1));
}
// Note: If anglemode === 'paper', this scale factor must be
pointDist *= SHRINK_FACTOR; // Adjust to slightly less than the computed distance

// Set the trace scale factor such that the longest vector will have
// a length equal to the computed pointDist
trace._scaleFactor = pointDist / trace._maxNorm;

// Note: If arrowref === 'paper', this scale factor must be
// multiplied by Math.sqrt(xa._m * ya._m), but we can't do that quite yet
// since the axis scales are not fully determined. Do it in plot step instead.
} else { // sizemode === 'raw'
// For raw sizemode, scale factor is always 1
} else {
// lengthmode === 'raw'
trace._scaleFactor = 1;
}

// Multiply scale factor by sizeref
trace._scaleFactor *= trace.sizeref;
// Multiply computed scale factor by lengthfactor attr
trace._scaleFactor *= trace.lengthfactor;

// Now we need to compute the arrow geometry for axis autorange
const xTipPositions = new Array(len);
Expand All @@ -148,7 +170,7 @@ module.exports = function calc(gd, trace) {
const yTailPositions = new Array(len);
var arrowLenX, arrowLenY;
// Compute the x- and y-positions of the tip of each arrow,
// assuming anglemode === 'data' (i.e. u/v are in data coordinates)
// assuming arrowref === 'data' (i.e. u/v are in data coordinates)
for(var i = 0; i < len; i++) {
var cdi = cd[i];
arrowLenX = cdi._u * trace._scaleFactor;
Expand All @@ -171,12 +193,12 @@ module.exports = function calc(gd, trace) {
}
}

if (anglemode === 'data') {
// If anglemode is 'data', we can use the arrow tip positions directly to expand the axes ranges
if (arrowref === 'data') {
// If arrowref is 'data', we can use the arrow tip positions directly to expand the axes ranges
trace._extremes[xa._id] = Axes.findExtremes(xa, xTipPositions.concat(xTailPositions), {padded: true});
trace._extremes[ya._id] = Axes.findExtremes(ya, yTipPositions.concat(yTailPositions), {padded: true});
} else { // anglemode === 'paper'
// TODO: For now, just do the same thing as for anglemode === 'data', but this is not correct.
} else { // arrowref === 'paper'
// TODO: For now, just do the same thing as for arrowref === 'data', but this is not correct.
// We actually need more sophisticated logic here, since this will give a bad result
// if the data aspect ratio is very different from the plot aspect ratio.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is there a follow up issue to track this?

trace._extremes[xa._id] = Axes.findExtremes(xa, xTipPositions.concat(xTailPositions), {padded: true});
Expand Down
6 changes: 3 additions & 3 deletions src/traces/quiver/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,11 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout
for(var j = 0; j < len; j++) traceOut.v[j] = 0;
}

coerce('anglemode');
coerce('arrowref');

// Sizing API inspired by cone, but not identical
coerce('sizemode');
coerce('sizeref');
coerce('lengthmode');
coerce('lengthfactor');
coerce('anchor');

// Arrow styling
Expand Down
20 changes: 8 additions & 12 deletions src/traces/quiver/plot.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,14 +102,10 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element, transition
lineSegments.exit().remove();

// Use maxNorm precomputed in calc
const maxNorm = trace._maxNorm || 0;
const anglemode = trace.anglemode;
const sizemode = trace.sizemode;
const sizeref = trace.sizeref;
const anchor = trace.anchor;

// Adjust scale factor if anglemode is 'paper'
const scaleFactor = (anglemode === 'paper') ? trace._scaleFactor * Math.sqrt(Math.abs(xa._m * ya._m)) : trace._scaleFactor;
const { anchor, maxNorm = 0, arrowref } = trace;

// Adjust scale factor if arrowref is 'paper'
const scaleFactor = (arrowref === 'paper') ? trace._scaleFactor * Math.sqrt(Math.abs(xa._m * ya._m)) : trace._scaleFactor;
const markerArrowsize = trace.marker.arrowsize;

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

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

// Compute arrow in data space
// Check whether arrowsize was set explicitly in the input trace
Expand Down
Loading
Loading