Skip to content

Add NetworkPlot: node-edge graph with FR, KK, and circle layouts#8

Merged
gabo515 merged 2 commits into
devfrom
feature/network-graph-plot
Apr 24, 2026
Merged

Add NetworkPlot: node-edge graph with FR, KK, and circle layouts#8
gabo515 merged 2 commits into
devfrom
feature/network-graph-plot

Conversation

@gabo515

@gabo515 gabo515 commented Apr 10, 2026

Copy link
Copy Markdown
Owner

Description

New plot type: node-edge network/graph diagram with three layout algorithms (Fruchterman-Reingold with Barnes-Hut, Kamada-Kawai, circle), four node shapes, edge labels, antiparallel edge curving, label repulsion, and full CLI support.

Type of change

  • New plot type

Checklist

Library (new plot type)

  • src/plot/network.rs — struct + builder methods
  • src/plot/mod.rspub mod + re-export
  • src/render/plots.rsPlot enum variant + bounds() / colorbar_info() / set_color()
  • src/render/render.rsadd_network(), added to render_multiple() match, skip_axes for pixel-space
  • src/render/layout.rsauto_from_plots() extended for legend sizing

Tests

  • tests/network_basic.rs — 18 tests: basic, directed, circle, self-loop, matrix, groups+legend, weighted, node sizes, disconnected, pinned positions, explicit colors, single-node self-loop, matrix+directed order, KK layout, edge labels, node shapes, antiparallel curving, label repulsion
  • tests/cli_basic.rs — 6 CLI tests: SVG output, content verification, matrix mode, KK layout, directed+weighted+group, bad layout error
  • cargo test --features cli,full — all 139 existing tests still pass
  • cargo clippy --features cli,full -- -D warnings — clean

CLI

  • src/bin/kuva/network.rs — Args struct + run()
  • src/bin/kuva/main.rs — module, Commands variant, match arm
  • scripts/smoke_tests.sh — 5 invocations (basic, directed+weighted+legend, circle, KK, matrix)
  • scripts/terminal_plots.sh — terminal rendering test
  • docs/src/cli/network.md — subcommand docs with full flags table
  • docs/src/cli/index.md — subcommand table entry
  • man/kuva.1 — regenerated

Documentation

  • examples/network.rs — 3 variants (basic, directed, grouped)
  • scripts/gen_docs.sh — invocation added
  • docs/src/plots/network.md — full docs page with embedded SVGs and complete API reference
  • docs/src/SUMMARY.md — links added (Plot Types + CLI)
  • docs/src/gallery.md — gallery card added
  • README.md — plot count 30 → 31

Housekeeping

  • CHANGELOG.md — entry added under [Unreleased]
  • src/prelude.rsNetworkPlot, NetworkNode, NetworkEdge, NetworkLayout, NodeShape re-exported

@gabo515

gabo515 commented Apr 10, 2026

Copy link
Copy Markdown
Owner Author

@claude review this jawn

@claude

claude Bot commented Apr 10, 2026

Copy link
Copy Markdown

Claude finished @gabo515's task in 3m 18s —— View job


Code Review

  • Read CLAUDE.md and repo guidelines
  • Review core implementation (src/plot/network.rs)
  • Review render logic (src/render/render.rs)
  • Review CLI (src/bin/kuva/network.rs)
  • Review tests
  • Review docs and examples
  • Post final review

Solid implementation overall — the feature is well-scoped, integrates cleanly with the existing render pipeline, and the test coverage is genuinely good. Here are the issues worth addressing, roughly in priority order.


Bugs / Correctness

1. Group legend color inconsistency when nodes have explicit colors (src/render/render.rs:10889–10901)

The group_map is built by picking the first node in each group and using its node.color (if any) as the group's color. But get_color (line 10904) always uses the per-node explicit color first. So if two nodes share a group but have different explicit colors, the legend swatch shows one color while some nodes render another.

Concretely: network_explicit_node_colors test puts A and B both in "Group1" with explicit red and blue respectively. The legend shows red (from A), but B renders blue. Fix this →

// Current — group adopts the explicit node color:
let c = node.color.clone()
    .unwrap_or_else(|| fallback[gi % fallback.len()].to_string());
group_map.push((g.clone(), c));

// Better — group always uses palette; explicit color remains a node-level override:
let c = fallback[gi % fallback.len()].to_string();
group_map.push((g.clone(), c));

2. Non-square matrix can panic (src/plot/network.rs:238–261)

with_matrix accepts Vec<Vec<f64>> but resolve_matrix iterates i in 0..n where n = indices.len(), then unconditionally indexes matrix[i]. If the caller passes fewer rows than labels, matrix[i] panics. Add a bounds check:

for i in 0..n {
    if i >= matrix.len() { continue; }   // <-- add this
    let j_start = if self.directed { 0 } else { i + 1 };
    for j in j_start..n {
        if j == i || j >= matrix[i].len() { continue; }

Design / API

3. compute_positions re-runs on every render (src/render/render.rs:10830)

let positions = net.compute_positions();

For KK layout on a graph with even 200 nodes this involves an O(n³) Dijkstra pass plus 200 outer × n inner Newton iterations. Every call to render_multiple pays this cost. Users who call render_multiple in a loop (e.g. generating many panels) will be surprised. Consider either caching positions in the struct or returning them from compute_positions for the user to supply back via with_node_position. A minimal approach: memoize behind a std::sync::OnceLock or just document the performance cliff clearly.

4. layout CLI flag should be a ValueEnum instead of a String (src/bin/kuva/network.rs:41–89)

#[arg(long, default_value = "force")]
pub layout: String,

A clap ValueEnum would provide shell-completion, early validation, and a proper --help listing of choices — all for free. The manual match + Err(format!(...)) on line 84 is reinventing what clap gives you.

5. No with_edges_styled / color+label via bulk API

with_edges() only accepts (S, S, f64) triples. There's no batch variant for edges that need both a color and a label. Minor ergonomics gap for callers who pre-build styled edge lists.


Minor / Polish

6. Dead j == i guard in undirected resolve_matrix (src/plot/network.rs:243)

In undirected mode j_start = i + 1, so j > i always and j == i is unreachable. The guard is harmless but misleading. Remove it or wrap the self-loop block differently.

7. gravity is unusually strong in FR layout (src/plot/network.rs:536)

gravity = 0.5 is one to two orders of magnitude higher than typical FR gravity implementations (usually 0.01–0.05). This will over-centralize sparse graphs and fight with the repulsive forces. Test with a path graph (A—B—C—D—E) and compare the spread to a standard FR implementation.

8. Label width uses character count, breaks for Unicode/CJK (src/render/render.rs:10836)

node.label.len() as f64 * 0.6 * font_size as f64

.len() is byte count, not character count. Labels with multibyte characters will produce wrong offsets. label.chars().count() is better (still wrong for CJK but at least correct for Latin + emoji). This is a pre-existing pattern in the codebase but you're introducing it again here.

9. add_network is defensively safe on empty edges but fragile (src/render/render.rs:10917–10919)

let w_min = net.edges.iter().map(|e| e.weight).fold(f64::INFINITY, f64::min);
let w_max = net.edges.iter().map(|e| e.weight).fold(f64::NEG_INFINITY, f64::max);

When edges is empty these leave w_min = INFINITY and w_max = -INFINITY. The subsequent edge loop doesn't execute so no NaN actually propagates — but the code is fragile. A node-only graph (no edges) is a valid input and shouldn't rely on the loop being empty for safety. Use net.edges.is_empty() early or use Option fold.


Tests

10. network_matrix_directed_order_independent counts all <path elements, not just arrows

let arrow_count = svg.matches("<path").count();
assert!(arrow_count >= 3, ...);

Self-loop arcs, node diamonds/triangles, and edge paths all use <path. This assertion passes vacuously for most renderings. Consider asserting on a more specific attribute like marker-end if arrowheads are rendered with SVG markers, or count something specific to your arrowhead path format. (As-is the test is still useful as a smoke test.)


Docs

The docs page is detailed and well-written. One small issue: the description at docs/src/plots/network.md:3 says "laid out with a force-directed (Fruchterman-Reingold) or circular algorithm" — it omits KK. Update to "force-directed (Fruchterman-Reingold), Kamada-Kawai, or circular."


Overall: The architecture is clean, the Barnes-Hut quadtree is a nice addition for large-graph scalability, and the builder API is consistent with the rest of the library. The bugs above (#1 and #2) are the only ones that could produce unexpected results for normal users. Everything else is polish.

@gabo515 gabo515 force-pushed the feature/network-graph-plot branch 5 times, most recently from 6a116ea to 11d4fbf Compare April 10, 2026 20:17
New plot type for network/graph visualization.

Layout algorithms:
- Fruchterman-Reingold force-directed (default), with Barnes-Hut
  O(n log n) approximation for n > 256
- Kamada-Kawai stress-based layout using all-pairs shortest paths
- Circle layout

Features:
- Edge-list and adjacency-matrix input (order-independent with
  with_directed via deferred matrix resolution)
- Directed (arrowheads) and undirected edges
- Weighted edges mapped to stroke width
- Self-loops as outward-facing bezier arcs
- Antiparallel edges automatically curved to avoid overlap
- Edge labels at midpoints
- Per-node color, size, group, and shape (circle/square/triangle/diamond)
- Node shapes use circumradius factor for correct edge clearance
- Group-based legend with category10 palette
- Label repulsion to avoid overlap, clamped to plot bounds
- Pinned node positions preserved through normalization
- Center gravity for disconnected component handling

CLI: kuva network (edge-list and --matrix modes, --layout
force/kk/circle, --directed, --labels, --repel-labels, etc.)

Includes 18 unit tests, 6 CLI integration tests, 5 smoke tests,
terminal smoke test, examples, doc assets, full documentation
pages, gallery entry, CLI reference, man page, and changelog.
@gabo515 gabo515 force-pushed the feature/network-graph-plot branch from 11d4fbf to fc1fd9c Compare April 10, 2026 20:24
@gabo515 gabo515 merged commit fd9a3d8 into dev Apr 24, 2026
2 checks passed
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