Skip to content

🌀 New Feature: Curved Quiver

Choose a tag to compare

@cvanelteren cvanelteren released this 14 Oct 09:06
· 37 commits to main since this release
bc0e8c3

This release introduces curved_quiver, a new plotting primitive that renders compact, curved arrows following the local direction of a vector field. It’s designed to bridge the gap between quiver (straight, local glyphs) and streamplot (continuous, global trajectories): you retain the discrete arrow semantics of quiver, but you gain local curvature that more faithfully communicates directional change.

streamplot_quiver_curvedquiver

What it does

Under the hood, the implementation follows the same robust foundations as matplotlib’s streamplot, adapted to generate short, curved arrow segments instead of full streamlines. As such it can be seen as in between streamplot and quiver plots, see figure below and above.

curved_quiver_comparison

The core types live in ultraplot/axes/plot_types/curved_quiver.py and are centered on CurvedQuiverSolver, which coordinates grid/coordinate mapping, seed point generation, trajectory integration, and spacing control:

  • _CurvedQuiverGrid validates and models the input grid. It ensures the x grid is rectilinear with equal rows and the y grid with equal columns, computes dx/dy, and exposes grid shape and extent. This means curved_quiver is designed for rectilinear grids where rows/columns of x/y are consistent, matching the expectations of stream/line-based vector plotting.

  • _DomainMap maintains transformations among data-, grid-, and mask-coordinates. Velocity components are rescaled into grid-coordinates for integration, and speed is normalized to axes-coordinates so that step sizes and error metrics align with the visual output (this is important for smooth curves at different figure sizes and grid densities). It also owns bookkeeping for the spacing mask.

  • _StreamMask enforces spacing between trajectories at a coarse mask resolution, much like streamplot spacing. As a trajectory advances, the mask is filled where the curve passes, preventing new trajectories from entering already-occupied cells. This avoids over-plotting and stabilizes density in a way that feels consistent with streamplot output while still generating discrete arrows.

  • Integration is handled by a second-order Runge–Kutta method with adaptive step sizing, implemented in CurvedQuiverSolver.integrate_rk12. This “improved Euler” approach is chosen for a balance of speed and visual smoothness. It uses an error metric in axes-coordinates to adapt the step size ds. A maximum step (maxds) is also enforced to prevent skipping mask cells. The integration proceeds forward from each seed point, terminating when any of the following hold: the curve exits the domain, an intermediate integration step would go out of bounds (in which case a single Euler step to the boundary is taken for neatness), a local zero-speed region is detected, or the path reaches the target arc length set by the visual resolution. Internally, that arc length is bounded by a threshold proportional to the mean of the sampled magnitudes along the curve, which is how scale effectively maps to a “how far to bend” control in physical units.

  • Seed points are generated uniformly over the data extent via CurvedQuiverSolver.gen_starting_points, using grains Ă— grains positions. Increasing grains increases the number of potential arrow locations and produces smoother paths because more micro-steps are used to sample curvature. During integration, the solver marks the mask progressively via _DomainMap.update_trajectory, and very short trajectories are rejected with _DomainMap.undo_trajectory() to avoid clutter.

  • The final artist returned to you is a CurvedQuiverSet (a small dataclass aligned with matplotlib.streamplot.StreamplotSet) exposing lines (the curved paths) and arrows (the arrowheads). This mirrors familiar streamplot ergonomics. For example, you can attach a colorbar to .lines, as shown in the figures.

From a user perspective, you call ax.curved_quiver(X, Y, U, V, ...) just as you would quiver, optionally passing color as a scalar field to map magnitude, cmap for color mapping, arrow_at_end=True and arrowsize to emphasize direction, and the two most impactful shape controls: grains and scale. Use curved_quiver when you want to reveal local turning behavior—vortices, shear zones, near saddles, or flow deflection around obstacles—without committing to global streamlines. If your field is highly curved in localized pockets where straight arrows are misleading but streamplot feels too continuous or dense, curved_quiver is the right middle ground.

Performance

Performance-wise, runtime scales with the number of glyphs and the micro-steps (grains). The default values are a good balance for most grids; for very dense fields, you can either reduce grains or down-sample the input grid. The API is fully additive and doesn’t introduce any breaking changes, and it integrates with existing colorbar and colormap workflows.

Parameters

There are two main parameters that affect the plots visually. The grainsparameters controls the density of the grid by interpolating between the input grid. Setting a higher grid will fill the space with more streams. See for a full function description the documentation.

curved_quiver_grains

The size parameter will multiply the magnitude of the stream. Setting this value higher will make it look more similar to streamplot.

curved_quiver_sizes

Acknowledgements

Special thanks to @veenstrajelmer for his implementation (https://github.com/Deltares/dfm_tools) and @Yefee for his suggestion to add this to UltraPlot! And as always @beckermr for his review.

What's Changed

Suggestions or feedback

Do you have suggestion or feedback? Checkout our discussion on this release.

Full Changelog: v1.62.0...v1.63.0