Repository navigation
[BUG]: Automargin for quiver plots is a little off with arrowref: 'paper' #7979
Description
Activity
I dug into this and can confirm the bug, pin down the exact root cause, and propose a fix path. Note:
arrowrefis the renamedanglemodeon the in-flightupdate-quiver-apibranch (PR #7945) — thecalc.jsTODO at the bottom of thearrowref === 'paper'branch even references this issue. So this is an active, unfixed code path.Root cause
The automargin/autorange expansion is computed twice, with two different coordinate interpretations, and they disagree whenever the axis scales differ:
-
In
calc.js(axis autorange, lines ~196-211): forarrowref === 'paper'the code still computes the tip/tail positions assuming a data interpretation (arrowLenX = cdi._u * trace._scaleFactor), and feeds those data-space tip coordinates toAxes.findExtremes. That is wrong for paper-space arrows. -
In
plot.js(lines ~126-127), the actual rendered arrow length is in pixels, with ascaleFactorthat is multiplied byMath.sqrt(Math.abs(xa._m * ya._m)):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;
With
arrowref === 'paper',pu/pvare pixel extents. Converting them back to data units (dx_data = pu / xa._m,dy_data = pv / ya._m) gives a tip offset that is notu * scaleFactor.
Why it "works" at 1:1 but breaks off-axis
The two interpretations coincide only when
|xa._m| == |ya._m|(square-ish plots / data). When they differ, the calc.js (data) estimate is wrong by a scale-ratio factor:actual_tip_x_data / (u * _scaleFactor) = sqrt(|ya._m| / |xa._m|) actual_tip_y_data / (v * _scaleFactor) = sqrt(|xa._m| / |ya._m|)Numerical check with the issue's example
x=[0], y=[0], u=[1], v=[0.1], plot 800×600,lengthmode='scaled',lengthfactor=1:- single point ⇒
pointDist = 1,_scaleFactor = 1/maxNorm ≈ 0.995. - calc.js expands x to ~
u * 0.995 ≈ 0.995data units ⇒ autorange settles near x-span ≈ 1.1, soxa._m ≈ 730 px/unit,ya._m ≈ 5480 px/unit. - Real rendered arrow (plot.js):
pu = u * sqrt(|xa._m * ya._m|) * 0.995 ≈ 1992 px— over twice the 800 px plot width, so the tip is drawn off the right edge. - The true data-space tip offset should be
1992/730 ≈ 2.73units, i.e.sqrt(|ya._m|/|xa._m|) ≈ 2.74×larger than what calc.js reserved. That matches the off-screen tip in the screenshot.
(I worked this out from the source on
update-quiver-api; happy to attach a standalone repro node script if useful.)Proposed fix
The cleanest, least-circular fix mirrors what the v4
quiver-edits-eklbranch already did for the v-component (which is exact in data space) and applies the same idea to u:- In
calc.js, forarrowref === 'paper', do not expand x by data-space tips. Instead stash per-point geometry and finish the x-axis expansion incross_trace_calc.js, where every quiver trace on the axis has been computed and the combined y-scale is known. - The horizontal pixel extent of each arrow is
xPixExt = pxPerY * baseLen * |unitu|(usingpxPerY = ya._length / yEstSpan, exactly as the existing crossTraceCalc already estimates). Convert that pixel extent into data units viaxa._mand feed it toAxes.findExtremesas pixel padding (ppadplus/ppadminus), so the axis reserves exactly the space the pixels will occupy — independent of the chicken-and-egg x-scale.
This is conceptually the same ppad-based expansion already implemented for the y-axis in the v4 branch, just extended to the x-axis for paper arrows. It converges in one pass because the x-expansion depends only on
pxPerY(from y), not on x itself.I held off on opening a PR because #7945 is actively renaming
anglemode→arrowref,sizemode→lengthmode,sizeref→lengthfactor, and I didn't want to collide with the in-flight refactor — but if maintainers want the fix landed onupdate-quiver-api, I can pick it up.-
@CAOShurong I've tagged this issue as
plotly-internalas it's not a straightforward fix. Please hold off on opening a PR.The problem is that the scaled pixel extent of the arrows depends on the computed automargin extents of the axes.
Desired behavior
Quiver automargin should always result in an initial plot area which contains all arrow endpoints.
Current behavior
The current quiver implementation calculates the arrow endpoints assuming
arrowref: 'data', uses those endpoints to compute the automargin extents, and then ifarrowrefis 'paper'`, applies an adjustment factor to the arrow endpoints.If the x:y aspect ratio of the data is pretty close to 1:1, this works OK because the arrow endpoints don't change much. But if the aspect ratio of the data is very far from 1:1, this can result in an initial plot where the arrow endpoints extend outside of the plotted area.
Example
Figure definition:
{ "data": [ { "type": "quiver", "uhoverformat": ".3f", "yhoverformat": ".3f", "xhoverfomrat": ".3f", "vhoverformat": ".3f", "x": [0], "y": [0], "u": [1], "v": [0.1], "arrowref": "paper" } ], "layout": { "width": 800, "height": 600, "showlegend": false } }Screenshot:
Notice how the tip of the arrow extends off the right side of the plot.
Fix
The relevant logic is around lines 200-206 in
src/traces/quiver/calc.js.It's a bit of a chicken-and-egg problem, because when
arrowrefis'paper', the data position of the arrow endpoints depends on the axis scales, which depend on the position of the arrow endpoints. I think there is probably an algebraic solution but haven't quite been able to figure it out. Alternatively there's probably an iterative or approximate approach we could apply here that would still be an improvement over the current behavior.