Skip to content

feature/QuiverPlot#70

Open
gabo515 wants to merge 13 commits into
Psy-Fer:devfrom
gabo515:dev
Open

feature/QuiverPlot#70
gabo515 wants to merge 13 commits into
Psy-Fer:devfrom
gabo515:dev

Conversation

@gabo515
Copy link
Copy Markdown
Contributor

@gabo515 gabo515 commented Apr 24, 2026

Description

Adds QuiverPlot — a 2-D vector-field renderer. Every arrow has a tail at (x, y) and a vector (u, v) in data coordinates; the renderer maps these through the axis transform, shrinks the shaft to butt against a triangular head, and wires the whole thing into the standard kuva layout / legend / colorbar pipeline.

Highlights:

  • Zero-config rendering. QuiverPlot::from_function((xlo, xhi, nx), (ylo, yhi, ny), |x, y| (u, v)) samples a closure on a regular grid; the longest arrow is auto-scaled to roughly one grid cell (0.9 × span / √n), so a naïve call produces a sensible plot regardless of the units of (u, v).
  • Arrow heads always look like arrows. Head length is proportional to shaft length (0.28 × shaft, clamped to [4, 14] pixels), so tiny arrows still have a visible head and long arrows don't grow gigantic ones. Explicit pixel overrides via with_head(len, half_w) / with_head_length / with_head_width.
  • Three pivot modes. QuiverPivot::Tail (default, arrow starts at (x, y)), Middle (centered on (x, y)), Tip (arrow points into (x, y)).
  • Magnitude colormap with automatic colorbar via with_magnitude_colormap(cmap, label). Priority for per-arrow color: per-arrow override > colormap > plot-level color.
  • Bounds + clipping. Default bounds include arrow tips so nothing overflows. Opt-in with_tight_bounds() uses tails-only bounds; with_clip_to_plot_area() / with_no_clip() control clipping independently. Tight bounds implies clipping when the clip flag isn't pinned.
  • Interactive tooltips. Each arrow wraps in a <g class="tt" data-x data-y data-u data-v data-mag> with a native <title> showing x, y, u, v, |v|, θ — so the embedded JS picks them up in --interactive mode.

Refactors (touch existing plots)

  • render_utils::arrow_head_path — shared arrow-head triangle helper. Replaces three inline copies: NetworkPlot directed edges, TextAnnotation arrows, and (new) QuiverPlot.
  • colorbar_linear(cmap, min, max, label) in render/plots.rs — consolidates six near-identical Arc<Fn> closures that built linearly-normalized colorbars across Heatmap, DotPlot, DicePlot, Contour, Clustermap, and Quiver. The 3-D colorbar_from_z path also routes through it now.

Type of change

  • New plot type
  • Refactor / housekeeping (shared helpers above)

Checklist

Library (new plot type)

  • src/plot/quiver.rsQuiverPlot + QuiverArrow + QuiverPivot; builder methods (with_arrow, with_arrows, with_colored_arrow, with_color, with_scale, with_auto_scale, with_shaft_width, with_head, with_head_length, with_head_width, with_head_ratio, with_head_min_px, with_head_max_px, with_color_map, with_color_range, with_color_legend_label, with_magnitude_colormap, with_legend, with_tight_bounds, with_clip_to_plot_area, with_no_clip, with_pivot); from_function constructor; #[cfg(test)] mod tests block for internal-accessor tests
  • src/plot/mod.rspub mod quiver + re-exports QuiverPlot, QuiverArrow, QuiverPivot
  • src/render/plots.rsPlot::Quiver variant + bounds() (tip-inclusive vs tight) + colorbar_info() (via new colorbar_linear) + set_color() + estimated_primitives() + From<QuiverPlot>
  • src/render/render.rsadd_quiver + render_quiver; wired into render_multiple dispatch and the palette auto-cycle group; not pixel-space so no skip_axes
  • src/render/layout.rshas_colorbar check added (magnitude colormap triggers colorbar margin) + has_legend check (legend label triggers legend margin); no category axis extensions needed
  • src/prelude.rsQuiverPlot, QuiverArrow, QuiverPivot re-exported

Tests

  • tests/quiver_basic.rs — 10 integration tests through the public API: basic render + shape counts, from_function exact-count + endpoint-inclusive sampling, colormap triggers colorbar, tight-bounds emits clip-path, proportional heads on small arrows, legend entry emission, empty-plot edge case (bounds None, no interactive groups), per-arrow color override vs plot-level, per-arrow override beats colormap. Encoding-resilient color assertions (hex / named / rgb(...)).
  • src/plot/quiver.rs #[cfg(test)] mod tests — 6 unit tests for internal accessors: pivot Tail/Middle shift, pivot Tip placement, auto-scale shrinks huge vectors, with_scale pins exact value, empty-plot fallback scale 1.0, all-zero-magnitude fallback scale 1.0.
  • tests/cli_basic.rs — 3 CLI tests: SVG output, colorbar widens canvas, --arrow-scale / --auto-scale mutual-exclusion error path.
  • cargo test --features cli,full1362 tests pass, 0 fail (includes upstream impl Into<f64> unification tests merged in).

CLI

  • src/bin/kuva/quiver.rsQuiverArgs with clap doc-comments + CliPivot ValueEnum mapped to QuiverPivot + run(). Uses with_arrows(iter) (not zip-chain of with_arrow calls).
  • src/bin/kuva/main.rsmod quiver, Commands::Quiver variant, match arm.
  • scripts/smoke_tests.sh7 quiver invocations covering basic, colormap + colorbar, pivot middle, pivot tip, explicit head dimensions, legend, and grid-on + tight bounds.
  • scripts/terminal_plots.sh — quiver entry added (visible in the terminal-rendering matrix).
  • tests/cli_basic.rs — SVG output + content-verification tests (above).
  • docs/src/cli/index.md — subcommand table entry.
  • man/kuva.1 — regenerated; kuva-quiver(1) referenced.

Documentation

  • examples/quiver.rs — generates docs/src/assets/quiver/{basic,colormap,source}.svg.
  • examples/data/quiver.tsv — saddle-field sample data used by smoke tests.
  • scripts/gen_docs.shquiver added to the EXAMPLES array.
  • docs/src/plots/quiver.md — full page: basic usage, scaling, pivot, coloring, styling, CLI flags reference table, embedded basic SVG.
  • docs/src/cli/quiver.md — CLI reference page with flag table and examples.
  • docs/src/SUMMARY.md — plot + CLI links added.
  • docs/src/gallery.md — gallery card added.
  • README.md — plot types table updated N/A — README has no plot-types table; only quick-start / docs / contributors sections.

Visual inspection

  • Verified new plot SVGs look correct — visually confirmed rotational field circulates, saddle field diverges on x and converges on y, source field radiates outward. Spot-checked all 3 pivot modes, tight_bounds clipping, colormap output, and legend rendering.
  • Scanned neighboring plots in test_outputs/ for layout regressions — The two refactors (arrow_head_path, colorbar_linear) do change behavior paths for 6 existing plot types; the test suite covers them but visual inspection was done
  • bash scripts/smoke_tests.sh183/183 outputs pass.
  • No text clipping / legend overlap / spurious axes in quiver outputs I inspected.

Housekeeping

  • CHANGELOG.md[Unreleased]Added (QuiverPlot) and Changed (arrow_head_path + colorbar_linear refactors).
  • README.md
image image

gabo515 added 6 commits April 23, 2026 16:44
QuiverPlot renders each arrow with a tail at (x, y) and vector (u, v).
Zero-config defaults: auto-scaled length so the longest arrow spans 85%
of the shorter tail span, proportional arrow heads (clamped 4–14 px) so
every arrow reads as an arrow regardless of magnitude.

- `from_function((xlo, xhi, nx), (ylo, yhi, ny), |x, y| (u, v))` for the
  common gridded-field case; builder API still available for irregular data.
- `QuiverPivot { Tail, Middle, Tip }` for where (x, y) sits on each arrow.
- Optional magnitude-driven colormap with automatic colorbar, via
  `with_magnitude_colormap(cmap, label)` or the individual setters.
- `with_tight_bounds()` opts into tail-only axis bounds; arrows are clipped
  to the plot rectangle so they don't spill into axis labels.
- Interactive-mode tooltips: each arrow wraps in a `<g class="tt" data-*>`
  with a native `<title>` showing x, y, u, v, |v|, θ.

Factored `render_utils::arrow_head_path` shared by QuiverPlot,
NetworkPlot directed edges, and TextAnnotation arrows.

CLI: `kuva quiver` — x/y/u/v column selectors, --arrow-scale / --auto-scale,
--pivot, --tight-bounds, --colormap, --colorbar-label, --shaft-width,
--head-length / --head-width, --legend. Docs at docs/src/plots/quiver.md
and docs/src/cli/quiver.md; example at examples/quiver.rs; smoke tests
covering basic + colormap; integration tests covering SVG output, colorbar
widening, and --arrow-scale / --auto-scale mutual exclusion.
- fix O(n²) in bounds() and add_quiver: effective_scale() iterates all
  arrows, and was being called per-arrow inside endpoints(). Hoist the
  scale once and add endpoints_with_scale() that takes the precomputed
  value. 1000-arrow fields now compute bounds in O(n) instead of O(n²).

- factor colorbar_linear(cmap, min, max, label) helper in plots.rs;
  absorb the six duplicate `Arc<Fn>` closures across Heatmap, DotPlot,
  DicePlot, Contour, Clustermap, and Quiver arms (and route the 3-D
  colorbar_from_z through it too).

- CLI --pivot now uses clap::ValueEnum (CliPivot -> QuiverPivot) matching
  the convention in bump/hexbin/treemap/sunburst, replacing the manual
  value_parser + unreachable!().

- split with_head(len, half_width) into with_head_length(l) /
  with_head_width(w) so the CLI can pass only the flag the user actually
  set without fabricating a 10-px default for the other dimension.

- use with_arrows(iter) in the CLI instead of a zip chain of with_arrow
  calls.

- strip narrating WHAT-comments from add_quiver / resolve_head; keep the
  one WHY comment about clipping with tight bounds.
Close the gaps from the PR template + recurring review patterns:

- tests/quiver_basic.rs (13 tests): dedicated integration suite covering
  from_function count, endpoint-inclusive sampling, pivot Tail/Middle/Tip
  shifts, auto-scale with huge vectors, explicit with_scale, colormap +
  colorbar, tight_bounds clip-path emission, proportional heads on small
  arrows, legend entry emission, empty input, per-arrow vs plot color
  fallback priority. Color assertions are encoding-resilient (match hex,
  named, rgb()).

- Fix degenerate auto-scale bug surfaced by the new tests: when all
  arrows are collinear on one axis, span was min(x, y) = 0 and scale
  fell back to 1.0. Now uses the non-zero axis when one is degenerate.

- Promote effective_scale / endpoints_with_scale / resolve_head /
  magnitude_extent to `pub` so the integration suite can exercise them.

- Non-default-config smoke tests (pivot middle, pivot tip, explicit
  head dims, legend) — was a common gap James had to fix post-merge.

- scripts/terminal_plots.sh entry for quiver.

- docs/src/cli/index.md subcommand table entry.

- docs/src/gallery.md gallery card.

- scripts/gen_docs.sh EXAMPLES list.

- Regenerated man/kuva.1 so kuva-quiver(1) is referenced.
The previous formula sized the longest arrow to `0.85 × shorter_span`,
which for an 8×8 grid produced arrows ~7× too long — the saddle-field
smoke test rendered as compressed vertical strips because the huge
scale factor crushed the v-component and dumped all tips near y=0.

Replaced with matplotlib's convention: target the longest arrow at
~one grid cell, approximated as `span / √n` for n arrows. The default
fraction is now 0.9 of the nearest-neighbor distance (was 0.85 of the
full span).

Verified visually: the regenerated doc SVGs and all six smoke-test
outputs now render as proper vector fields (rotational field shows
clear circulation; saddle field shows the expected diverging pattern).
Background gridlines compete visually with arrow shafts and add no
information in quiver plots — each arrow already shows both position
and direction. Pass --no-grid in smoke tests and .with_show_grid(false)
in the integration test render helper.
After changing the auto-scale formula to match matplotlib (longest arrow
≈ one grid cell), four docs/help strings still described the old "85% of
shorter span" formula:

- docs/src/plots/quiver.md — example dropped the now-redundant
  .with_auto_scale(0.85) call; scaling section rewritten to describe the
  √n grid-cell heuristic; CLI flag table default updated to 0.9.
- docs/src/cli/quiver.md — default updated to 0.9 with the grid-cell
  description.
- src/bin/kuva/quiver.rs clap doc — same, feeds into --help and the man
  page.

Tests: fixed the stale "fill ~85% of x span" comment in
test_quiver_auto_scale_picks_sensible_length. Moved create_dir_all into
the render() helper so parallel test runs can't race on the first fs::write.

Downgraded QuiverPlot::resolve_head from pub back to pub(crate) — it
takes a pixel argument that only makes sense inside the render pipeline,
and no test now needs it publicly.
@gabo515 gabo515 marked this pull request as draft April 24, 2026 17:59
gabo515 added 2 commits April 24, 2026 11:13
1. with_head_min_px / with_head_max_px builders — head_min_px and
   head_max_px were pub but had no setters, forcing users to mutate
   fields directly.

2. Stronger color-priority test — adds a case where both a colormap
   and a per-arrow color override are set; the per-arrow override must
   win. The previous test only covered per-arrow vs. plot-level.

3. CHANGELOG calls out the arrow_head_path and colorbar_linear
   refactors in their own Changed section, since they touch six other
   plot types besides quiver.

4. Downgrade effective_scale / endpoints_with_scale / magnitude_extent
   back to pub(crate) — they were promoted purely for integration
   tests, which was a weak reason to widen the public surface. Moved
   the 4 affected tests into a #[cfg(test)] mod tests block inside
   src/plot/quiver.rs. Added two small negative-path tests while I was
   there (fallback scale of 1.0 when arrows are empty or all zero).

5. Decouple clip-to-plot from tight_bounds. New clip_to_plot_area:
   Option<bool> field with should_clip() resolver; builders
   with_clip_to_plot_area() (force on) and with_no_clip() (force off).
   CLI gets --clip / --no-clip (mutually exclusive). Default behaviour
   preserved: tight_bounds still implies clipping when the user hasn't
   pinned the flag explicitly.

6. Strengthen empty-plot test — now asserts Plot::Quiver::bounds()
   returns None and that no interactive groups are emitted. Previously
   only asserted <svg presence.

7. Added "quiver grid on + tight bounds" smoke test so the default
   grid rendering is exercised with quiver at least once.

Also: drop "matplotlib" references from new quiver docs / comments /
CHANGELOG — describe the behaviour directly without attributing it.
# Conflicts:
#	CHANGELOG.md
#	scripts/smoke_tests.sh
#	src/render/render.rs
@gabo515 gabo515 changed the title Dev feature/QuiverPlot Apr 24, 2026
gabo515 added 3 commits April 24, 2026 13:28
10 new tests across 4 files addressing coverage thin spots:

tests/quiver_basic.rs (5 new):
- interactive-mode emits one <g class="tt" data-x/y/u/v/mag> per arrow,
  relaxed to [24, 25] to tolerate the zero-magnitude arrow at the grid
  center being skipped.
- with_clip_to_plot_area() alone (no tight_bounds) still emits the
  clip-path.
- with_no_clip() + with_tight_bounds() suppresses the clip-path.
- with_color_range pins the colorbar axis extent (user-set max appears
  on the colorbar tick axis, not the data extent).
- with_head_length / with_head_width overrides change the rendered
  output vs. the proportional default.

src/plot/quiver.rs (2 new internal tests):
- auto_scale_matches_grid_cell_formula pins the exact fraction ×
  span / (√n × max_mag) value so a regression in the formula is
  caught, not just "scale < 0.1".
- auto_scale_honors_custom_fraction — passing with_auto_scale(0.45)
  halves the scale vs. the 0.9 default.

tests/heatmap.rs (1 new):
- test_heatmap_colorbar_regression pins Viridis gradient endpoints
  (#440154 / #fde725) so silent drift in the refactored
  colorbar_linear helper would be caught.

tests/network_basic.rs (1 new):
- test_network_directed_arrowhead_format_regression verifies the
  refactored arrow_head_path helper still emits the M/L/L/Z triangle
  shape for every directed edge.

tests/cli_basic.rs (4 new):
- --pivot middle produces different SVG than the tail default.
- --tight-bounds emits a clip-path.
- --no-clip + --tight-bounds suppresses the clip-path.
- --head-length / --head-width are accepted and change the output.
Blocking:
- All QuiverPlot data-entry builders now accept impl Into<f64>, matching
  the convention freshly merged from PR Psy-Fer#68 (BarPlot / ForestPlot /
  WaterfallPlot / ...). Updated: with_arrow, with_arrows, with_colored_arrow,
  with_scale, with_auto_scale, with_shaft_width, with_head, with_head_length,
  with_head_width, with_head_ratio, with_head_min_px, with_head_max_px,
  with_color_range. Callers can now pass i32/u32/f32 etc. without `as f64`.

Doc fix:
- with_auto_scale doc-comment updated — was still describing the
  pre-√n "fraction of the shorter tail-span" formula with the wrong
  default (0.85 → 0.9). The field doc on auto_scale_fraction was
  already correct; this was the only remaining stale reference.

Robustness:
- with_arrow / with_arrows / with_colored_arrow now silently drop
  non-finite (NaN / ±inf) inputs instead of letting them propagate
  through bounds() / effective_scale() / render. Documented in the
  builder doc-comments.

Tests:
- test_quiver_with_arrow_accepts_integer_types — covers the i32 / u32 /
  f32 paths through impl Into<f64>.
- test_quiver_drops_non_finite_arrows — verifies NaN / ±inf rows are
  skipped at push time.
- test_quiver_with_arrows_drops_non_finite — same for the iterator
  variant.
- test_quiver_with_legend_emits_entry strengthened — now also asserts
  the legend Line glyph adds at least one <line> element vs. the
  no-legend baseline.
- test_quiver_cli_colorbar_label and test_quiver_cli_legend — close
  the last two CLI flag-coverage gaps flagged in review.
1. NaN guards on numeric config setters — with_scale, with_auto_scale,
   with_shaft_width, with_head, with_head_length, with_head_width,
   with_head_ratio, with_head_min_px, with_head_max_px, with_color_range
   now debug_assert that their input is_finite (and shaft_width is
   non-negative). Release builds still accept NaN/inf silently and
   produce empty plots — the docs on with_scale spell out that
   degeneration explicitly.

2. Tighter legend-glyph test — uses a non-default plot color (crimson)
   so the legend's <line> glyph can be unambiguously identified by
   stroke. Resilient to encoding (matches "crimson", "#dc143c", or
   "rgb(220,20,60)") per the kuva PR-review-pattern guidance.

3. Doc note on with_arrows — clarifies that all four tuple positions
   must use the same numeric type within the iterator (limitation of
   the generic shape; matches BarPlot::with_bars and friends).
@gabo515 gabo515 marked this pull request as ready for review April 24, 2026 23:18
@gabo515
Copy link
Copy Markdown
Contributor Author

gabo515 commented Apr 28, 2026

I will deal with the merge conflicts once the new release happens :)

@Psy-Fer
Copy link
Copy Markdown
Owner

Psy-Fer commented Apr 29, 2026

haha yea good idea :) not long now

gabo515 added 2 commits May 12, 2026 11:42
# Conflicts:
#	CHANGELOG.md
#	docs/src/SUMMARY.md
#	docs/src/gallery.md
#	scripts/gen_docs.sh
#	src/bin/kuva/main.rs
#	src/plot/mod.rs
#	src/prelude.rs
#	src/render/plots.rs
#	src/render/render.rs
#	src/render/render_utils.rs
#	tests/heatmap.rs
Upstream ran cargo fmt across the project on 2026-05-04 (commit
2f7b743 "cargo fmt fixes"). The quiver-PR code I wrote between then
and now wasn't fmt-clean, so this aligns it with upstream's style
ahead of the next round of review.

10 files reformatted (~430 lines): examples/quiver.rs,
src/bin/kuva/quiver.rs, src/plot/quiver.rs, src/render/annotations.rs,
src/render/plots.rs, src/render/render.rs, tests/cli_basic.rs,
tests/heatmap.rs, tests/network_basic.rs, tests/quiver_basic.rs.

No behavioral changes. Tests still pass (70 + 15 + 22 + 18 across the
quiver / heatmap / network / cli_basic suites); clippy clean.
@gabo515
Copy link
Copy Markdown
Contributor Author

gabo515 commented May 12, 2026

I think it is ready

@Psy-Fer
Copy link
Copy Markdown
Owner

Psy-Fer commented May 13, 2026

Yea looks good mate. Just been sorting out some raster/backend upgrades before going back into more plot additions. Current sprint is around that and some cli fixes/additions. Then back to regular plot expansion/features.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants