Skip to content

[BUG]: Automargin for quiver plots is a little off with arrowref: 'paper' #7979

Description

@emilykl

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 if arrowref is '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:

Image

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 arrowref is '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.

Activity

  1. CAOShurong commented on Aug 24, 2026

    @CAOShurong
    Contributor

    I dug into this and can confirm the bug, pin down the exact root cause, and propose a fix path. Note: arrowref is the renamed anglemode on the in-flight update-quiver-api branch (PR #7945) — the calc.js TODO at the bottom of the arrowref === '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:

    1. In calc.js (axis autorange, lines ~196-211): for arrowref === '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 to Axes.findExtremes. That is wrong for paper-space arrows.

    2. In plot.js (lines ~126-127), the actual rendered arrow length is in pixels, with a scaleFactor that is multiplied by Math.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/pv are pixel extents. Converting them back to data units (dx_data = pu / xa._m, dy_data = pv / ya._m) gives a tip offset that is not u * 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.995 data units ⇒ autorange settles near x-span ≈ 1.1, so xa._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.73 units, 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-ekl branch already did for the v-component (which is exact in data space) and applies the same idea to u:

    • In calc.js, for arrowref === 'paper', do not expand x by data-space tips. Instead stash per-point geometry and finish the x-axis expansion in cross_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| (using pxPerY = ya._length / yEstSpan, exactly as the existing crossTraceCalc already estimates). Convert that pixel extent into data units via xa._m and feed it to Axes.findExtremes as 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 on update-quiver-api, I can pick it up.

  2. emilykl commented on Aug 24, 2026

    @emilykl
    ContributorAuthor

    @CAOShurong I've tagged this issue as plotly-internal as 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions