Skip to content

Refactor and extend plot.igraph(): renderer pipeline, and new features#2711

Draft
schochastics wants to merge 31 commits into
mainfrom
refactor-plotting
Draft

Refactor and extend plot.igraph(): renderer pipeline, and new features#2711
schochastics wants to merge 31 commits into
mainfrom
refactor-plotting

Conversation

@schochastics

Copy link
Copy Markdown
Contributor

Summary

A substantial refactor and extension of the base-graphics plotting subsystem
(plot.igraph() and friends), staged as a long series of small, independently
reviewable commits. The work splits the old monolithic draw path into clear
stages (resolve aesthetics → geometry → draw list → render), lands several new
user-facing features on top of that foundation, fixes a real bug, and documents
the whole thing in a new article.

Everything new is opt-in / default-no-op: the vdiffr snapshot suite stays
the correctness gate, and default plot(g) output is unchanged unless a commit
is explicitly marked breaking.

Internal refactor (B/S/P/P2 commits)

  • Stage-1 edge aesthetic table (vctrs) with strict recycling; non-loop edges
    routed through it.
  • Extracted pure geometry helpers (arrowhead shape, shaft endpoints, loop
    angles, curved splines) and a vertex-label phase.
  • Strict recycling for plotting aesthetics — per-element args must be length
    1 or vcount/ecount (igraph 3.0.0 behaviour; marked breaking).
  • add_shape() now validates the clip/plot signatures.
  • Removed dead defaults and duplicate tables; named magic constants.

New features

  • F1 — scales + legends: scale_color() / scale_colour() / scale_size()
    map data to an aesthetic and record the mapping, so plot() draws a
    categorical legend, a continuous colourbar, or a size legend. New legend
    argument controls placement; guides live in their own resize-stable region.
  • F2 — label repel: vertex.label.repel = TRUE nudges overlapping labels
    apart with leader lines.
  • F3 — edge styles: edge.style routing — straight / arc / elbow /
    diagonal.
  • F4 — gradients + alpha: edge.gradient (source→target colour),
    vertex.alpha / edge.alpha.
  • F5 — draw-list IR + SVG: base drawing routed through a renderer
    abstraction; a record renderer materializes a backend-neutral draw list that a
    new exported as_svg() consumes, emitting per-vertex/edge element IDs and
    <title> tooltips (lightweight interactivity, no JS).
  • F6 — label readability: vertex.label.halo / edge.label.halo
    (shadowtext-style outline) and label_top() to declutter dense graphs by
    labelling only the top-N vertices by a metric.

Bug fix

  • Named per-vertex numeric aesthetics (e.g. vertex.size = scale_size(degree(g)),
    since degree() is named) propagated names into i.edge_label_pos(), where
    c(x = …, y = …) produced names like x.Alice instead of x/y and crashed
    with "subscript out of bounds". Fixed at the source with regression tests.

Docs

  • New web-only pkgdown article "Plotting graphs"
    (vignettes/articles/plotting.Rmd) walking through every plotting feature —
    old and new — at least once, including an inline embedded SVG. Registered in
    the navbar; the main igraph.Rmd plotting section trimmed to a teaser that
    links to it.

Testing

  • Extended the plotting test suite up front; new tests for scales, repel, edge
    styles, gradients, the renderer/SVG path, label halos, label_top(), and the
    named-aesthetic regression.
  • Full plot suite green; the article knits cleanly end-to-end (strict
    error = FALSE).

🤖 Generated with Claude Code

schochastics and others added 25 commits June 23, 2026 21:31
Add a behavior-locking safety net for the plotting subsystem before the
planned refactor of parameter resolution and edge subsetting.

- New test-plot-params.R: unit tests for i.parse.plot.params() precedence,
  recycling, and NA handling, plus the getter helpers (i.get.arrow.mode,
  i.get.labels, i.get.main/xlab, igraph.check.shapes, curve_multiple,
  i.rescale.vertex).
- test-plot.shapes.R: clip-math tests for non-circle shapes, including the
  per-endpoint vertex.size vector selection.
- test-plot.R: integration snapshots for vector edge params across loops and
  non-loops, auto-curved multi-edges, NA-attribute warning, multi-group
  mark.groups, label.dist/degree, add=TRUE overlay, and palette indexing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
P1.4: drop the redundant second i.default.values[["edge"/"plot"]] assignments
in plot.common.R (exact duplicates).

P1.5: introduce VERTEX_SIZE_SCALE (1/200 size->user-coord factor) and
ARROW_WIDTH_FACTOR (1.2/4 arrowhead scaling) in plot.R, applied across
plot.igraph() and igraph.Arrows(). Behaviour-preserving; rglplot() occurrences
left untouched (no snapshot coverage here).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add R/plot-aes.R with i.aes_table()/i.edge_aes_table(), which package resolved
per-edge plotting parameters into a vctrs data frame that can be sliced by edge
index. Use it in plot.igraph() to replace the 11x repeated
`if (length(x) > 1) x[loops.e]` block for loop edges with a single table build +
vec_slice().

Behaviour-preserving: recycling keeps the historical rep(length.out=) semantics
(downstream mapply/igraph.Arrows recycle anyway), so the strict-recycling change
flagged in the plan is intentionally NOT adopted here (it needs a revdep check).
The non-loop block's entangled per-arrow-code curved handling is left for the
P2.4 consolidation step.

Also document the resolution precedence on i.parse.plot.params() and add unit
tests for the new table helpers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pull the arrowhead-outline computation out of the igraph.Arrows() drawing loop
into i.arrowhead_shape(cin, w, delta), a pure function returning the head
outline in polar form. Adds a device-free unit test for it (previously the
geometry was only exercised indirectly via the standard-arrow* snapshots).

Scoped to the arrowhead geometry; the shaft-endpoint and curved-path math
remain inline (more entangled with drawing/mutation) and can follow in the
P2.4 consolidation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the four nested functions (point.on.cubic.bezier, compute.bezier,
plot.bezier, loop) from inside plot.igraph()'s body to file-level internal
functions, renamed with an i. prefix. i.plot.bezier in particular avoids being
mistaken for an S3 plot() method for class "bezier". This removes ~155 lines
from the plot.igraph() body and makes the helpers independently reusable.

The functions capture no enclosing state; loop's self-referential arg defaults
(always supplied by the mapply call site) become harmless literals. Also drops
a dead duplicate `ec <- edge.color` extraction left over from the Stage 1
change. Behaviour-preserving (loop-graph / multi-loops snapshots unchanged).

The larger phase-helper extraction (plot_vertices/plot_labels) is deferred:
threading the 40+ locals is higher risk than snapshot-only verification covers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two behaviour-preserving render-path cleanups:
- Extract i.hide_zero_frame() for the "frame.width <= 0 hides the border" rule
  that was copy-pasted into the circle/square/rectangle plot functions.
- Extract i.init_plot_canvas() for the empty-canvas plot() setup, so
  plot.igraph() reads as setup -> edges -> vertices -> labels.

Snapshots unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
BREAKING CHANGE (igraph 3.0.0): a per-element plotting aesthetic must now be
length 1 or exactly vcount()/ecount(). Previously a wrong-length vector (e.g. 3
colors for 5 vertices) was silently recycled, masking user mistakes; it is now
a clear error via i.check_aes_lengths().

Applied to the unambiguous per-element vertex/edge aesthetics. Intentionally
excluded: arrow.mode (its "a:" form reads a vertex attribute, so it can be
vcount-long), and label.adj / pie / raster, which have non-per-element length
semantics.

Adds unit tests for i.check_aes_lengths plus plot()-level error/valid-length
tests and error snapshots.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Build the edge aesthetic table once (now including arrow.width) before both the
loop and non-loop edge blocks, and slice it per block with vec_slice(). This
replaces the remaining per-parameter `if (length(x) > 1) x[idx]` subsetting and
fixes two latent bugs in the mixed-arrow-mode path:

- `curved` was sliced to non-loop edges and then re-expanded with
  `rep(curved, length.out = ecount)[nonloops.e]`, double-indexing and scrambling
  curvature values.
- `arrow.size` / `arrow.width` were not subset by the per-arrow-code `valid`
  mask, so igraph.Arrows() received the leading elements rather than the
  matching ones.

Both now index consistently by `valid`. BREAKING: plots that combined mixed
arrow modes with per-edge curved/arrow.size/arrow.width render differently (now
correctly) — the vector-edge-params-loops snapshot is updated accordingly, and a
new mixed-modes-curved regression snapshot covers the path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pull two self-contained phases out of plot.igraph()'s body:
- i.loop_angles(graph, layout, loops.v): the "flower-petal" distribution of
  self-loops into the largest angular gap at each vertex, returning aligned
  angle/narrowing vectors. Drops two dead locals (loop_table, loop_idx). Now
  unit-testable.
- i.draw_vertex_labels(...): the vertex-label placement + drawing block, with
  xpd scoped to the helper.

Behaviour-preserving (loop / label snapshots unchanged); adds a unit test for
i.loop_angles. The loop/non-loop edge-drawing blocks remain inline for now.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extract the remaining per-edge geometry from the igraph.Arrows() drawing loop
into pure helpers:
- i.arrow_shaft_endpoints(): shaft segment endpoints, pulled back at the
  arrowed end(s) per `code`.
- i.edge_label_pos(): straight-edge label anchor (2/3 along the edge).
- i.curved_spline(): the X-spline control points + curve for a curved edge.

igraph.Arrows() now computes geometry via these helpers and only draws. Adds
device-free unit tests for the two pure helpers; arrow snapshots unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
add_shape() now checks that the clip and plot functions accept the arguments
igraph calls them with (clip(coords, el, params=, end=) and
plot(coords, v=, params=)), via i.check_shape_fun(). Functions taking `...`
are exempt. A malformed custom shape now fails at registration with a clear
message instead of cryptically at plot time.

Scoped to signature validation; the larger metadata-driven shape registry
(per-shape parameter table + plot-time "unknown param" validation) is deferred
— it would change what shapes() returns and risks false positives.

Adds tests for the new validation, with cleanup so test shapes don't leak into
the global registry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
i.plot.default carried `frame = FALSE`, but plot.igraph() reads `frame.plot`
(which falls back to `axes` when unset), so the entry was never used. Remove the
misleading dead config and document the actual behaviour. No behaviour change
(rescale-coords snapshot with axes = TRUE is unchanged).

The file splits in B7 (plot.common.R / layout.R) are deferred: pure code-motion
with high merge-conflict cost and no behaviour value, best done once the
restructuring churn has settled.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add scale_color()/scale_colour() and scale_size(): pass them to vertex/edge
colour or size arguments to map a data column to the aesthetic AND draw a
matching guide automatically, e.g.
  plot(g, vertex.color = scale_color(V(g)$group))
  plot(g, vertex.size  = scale_size(degree(g)))

- scale_color(): discrete data -> categorical_pal() + a legend; numeric data ->
  a sequential colour ramp + a colorbar.
- scale_size(): numeric data -> a size range (optional transform) + a size
  legend.
- A new `legend` argument to plot.igraph() controls placement ("topright"
  default, any corner keyword) or suppression (FALSE).

Implemented natively on base graphics (R/plot-scales.R): scales are resolved to
plain aesthetic vectors before i.parse.plot.params() (whose recycling strips
attributes), with their guides collected and drawn after the vertices/labels.
Wrong-length scale data is caught by the existing strict-recycling check.

Adds unit tests (test-plot-scales.R) and vdiffr snapshots covering discrete,
continuous, size, combined, edge-colour, repositioned, and suppressed guides.

NAMESPACE exports are added via @export (regenerated in CI).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Legends/colorbars previously sat in a plot corner and overlapped the graph.
Now they are drawn in reserved outer-margin space on one side, never over the
graph:

- New `legend` semantics: TRUE/"right" (default), "left", "top", "bottom"
  (corner keywords map to the nearest side); FALSE suppresses.
- "top"/"bottom" arrange legend entries horizontally and draw a horizontal
  colorbar; "left"/"right" stack vertically with a vertical colorbar.
- plot.igraph() reserves par("mar") on the chosen side before drawing (scaled
  to label width for left/right) and restores it on exit; guides are drawn with
  xpd = NA into that margin.
- Multiple guides stack (down for left/right, across for top/bottom).

Snapshots for the scale cases are regenerated for the new placement; adds
horizontal bottom-legend and top-colorbar snapshots.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The previous corner/margin placement positioned guides in data coordinates,
which (with asp = 1) drift as the device is resized and could be off-centre or
disappear. Switch to the standard two-region approach: split the device with
par("fig") into a plot region and a guide region (device-relative NDC, so it is
stable across resizes), draw the graph in the plot region, then draw the guides
into the guide region via par(new = TRUE).

Guides are centred within their region (two-pass measure then layout): stacked
vertically for left/right, laid out in a row for top/bottom, with horizontal
colorbars for top/bottom and vertical for left/right.

Replaces i.draw_guides/i.draw_one_guide/i.draw_colorbar/i.legend_reserve_mar
with i.legend_fig + i.draw_guides_region + i.guide_draw + i.colorbar. Legends
are only drawn when add = FALSE (a guide region can't be carved out of an
existing plot). Scale snapshots regenerated for the new placement.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add an opt-in ggrepel/Gephi-style label-adjust: with vertex.label.repel = TRUE,
overlapping vertex labels are iteratively nudged apart and a thin leader line
connects each moved label to its original anchor.

- i.repel_labels(): deterministic force layout — labels repel along the axis of
  smaller box overlap and are sprung back toward their anchor. Pure (takes label
  box half-sizes), so it is unit-testable without a device.
- i.draw_vertex_labels() gains a `repel` argument: it measures each non-empty
  label's box (strwidth/strheight), runs the repel, draws leader lines for
  labels that moved, then draws the text at the adjusted positions.
- New `label.repel` vertex default (FALSE) resolved in plot.igraph(); documented
  in the plotting parameters. Default rendering is unchanged.

Adds unit tests for i.repel_labels and a vdiffr snapshot of repelled clustered
labels.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a per-edge `edge.style` aesthetic selecting the routing of non-loop edges:
"auto" (default; straight, or arc when edge.curved != 0 — unchanged behaviour),
"straight", "arc" (curved; strength from edge.curved or a default), "elbow"
(two-corner orthogonal connector), and "diagonal" (smooth S-curve with
axis-aligned ends).

- New pure geometry helpers i.elbow_path() and i.diagonal_path() (the latter
  reuses the existing Bezier helpers); device-free and unit-tested.
- igraph.Arrows() gains a `style` arg and two new draw branches; the existing
  straight/arc branches are kept byte-identical so no existing snapshot changes.
  Arrowheads for elbow/diagonal align with the path's end segment.
- Wired through i.edge.default, the edge aesthetic table, and the non-loop
  dispatch; resolved and validated in plot.igraph() (unknown style -> error).
  Documented in the plotting parameters; ignored for loops and rglplot().

Adds unit tests for the path helpers and vdiffr snapshots for elbow, diagonal,
mixed per-edge styles, and forced arcs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add two opt-in edge/vertex aesthetics, both no-ops by default:

- edge.gradient: when TRUE, an edge is drawn as a colour gradient from its
  source vertex's colour to its target vertex's colour (a direction cue), with
  the arrowhead taking the target colour. Implemented via i.draw_gradient_path()
  (arc-length resample + per-segment colorRamp); igraph.Arrows() gains
  `gradient`/`col.to` args and a gradient branch per edge style. Source/target
  colours come from the base vertex fill (resolved lazily, only when a gradient
  is used).

- vertex.alpha / edge.alpha: per-element opacity in [0, 1], folded into the
  fill colours via i.apply_alpha() (multiplies existing alpha; strict no-op when
  all 1, so existing plots/snapshots are unchanged). edge.alpha also applies to
  gradient endpoints; vertex.alpha is injected into vertex.color before the
  shape draw. Frame colour, pie slices and labels are unaffected.

Wired through i.vertex.default/i.edge.default, the edge aesthetic table, and the
non-loop dispatch; documented in the plotting parameters. Adds unit tests for
i.apply_alpha and vdiffr snapshots for gradients and translucency. Gradients are
ignored for self-loops and by rglplot().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Introduce a Stage-3 rendering indirection: all of plot.igraph's 2D drawing now
emits primitives through i.r_*() dispatchers that forward to a "current"
renderer (R/plot-render.R), instead of calling base graphics directly. The
default base renderer simply calls the matching base-graphics function, so
on-screen output is unchanged (the full plotting snapshot suite passes
byte-identical).

Migrated every 2D draw site to the dispatchers: the canvas, edges/arrowheads
(igraph.Arrows), loop Beziers, the gradient path, mark-group xsplines, vertex
and edge labels, and the vertex shape $plot functions (circle/square/rectangle
symbols, pie polygons, sphere/raster images). The public shape (coords, v,
params) contract is unchanged -- shapes call the i.r_* dispatchers internally.

This is the foundation for a record/SVG renderer (phase 2) consuming the same
primitive stream. No user-facing change. Adds unit tests for the indirection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a recording renderer that materializes the Stage-3 draw list (primitives
with hex-canonicalised colours and their vertex/edge group), and a small SVG
writer that consumes it. New exported as_svg(graph, file, width, height,
tooltips, ...) renders a graph to standalone SVG reusing plot.igraph's geometry
(via an offscreen measurement device), emitting per-vertex <g id="vertex-N">
with <title> tooltips and per-edge <g id="edge-N"> groups -- lightweight
hover/click interactivity with no JavaScript.

Coverage: vertices (circle/square/rectangle), every edge style
(straight/arc/elbow/diagonal/gradient), arrowheads, labels, mark groups and pie
slices render; sphere/raster shapes draw as a placeholder box (v1). NA/empty
labels are skipped to match base output. Per-edge grouping is threaded through
igraph.Arrows via a new `ids` arg; the base renderer ignores grouping, so base
plotting output is unchanged (snapshots byte-identical).

Adds tests for the draw list and the SVG (well-formedness, per-element ids,
tooltips, file output).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add two opt-in, default-no-op label features to plot.igraph():

- Label halo: a shadowtext-style legibility outline behind vertex and edge
  label text, via new i.r_text_halo() and `vertex.label.halo` /
  `edge.label.halo` (colour, default NA) + `*.halo.width` params. With NA the
  draw path is byte-identical to before, so existing snapshots are unchanged.

- label_top(): an exported helper returning a label vector with NA outside the
  top-N by a metric, composing with plot.igraph()'s NA-omission to declutter
  dense graphs (mirrors the scale_*() helper style).

Halo params flow through i.edge_aes_table, the default registry, and the
length checks; routed through all three 2D label sites (vertex, non-loop
edge, loop edge). Adds label_top unit tests, halo vdiffr snapshots, and an
as_svg halo smoke test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a web-only pkgdown article (vignettes/articles/plotting.Rmd) that walks
through every plotting feature at least once: layouts, vertex shapes/colour/
size/alpha, all label controls plus repel and halos, edge width/lty/arrows,
curved fans and the arc/elbow/diagonal edge styles, edge alpha and gradients,
palettes, scales & legends (categorical legend + colourbar + size legend),
label_top() decluttering, mark.groups highlighting, annotations, custom shapes,
igraph_options precedence, and as_svg() with an inline embedded SVG.

Register it in the _pkgdown.yml Articles navbar, and trim the now-redundant
plotting reference tables in igraph.Rmd down to a teaser that links to the new
article.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A named per-vertex numeric aesthetic — e.g. vertex.size = scale_size(degree(g)),
since degree() returns a named vector — propagated its names through edge
clipping into i.edge_label_pos(). There, c(x = <named>, y = <named>) produced
names like "x.Alice" instead of "x"/"y", so the downstream lab[["x"]] /
lab[["y"]] lookups failed with "subscript out of bounds".

unname() the components in i.edge_label_pos() so the result is always named
x/y regardless of the inputs. Add regression tests covering a named scale, a
named raw size vector, and i.edge_label_pos() directly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two examples produced empty/partial figures:

- Self-loops: make_graph(~ A -+ A, ...) silently drops self-loops, so only the
  A->B edge rendered. Build the edge list explicitly with
  make_graph(c("A","A","A","B","B","B"), directed = TRUE) and add a fixed
  layout/margin so both loops are clearly visible.

- Custom triangle shape: with a uniform shape, plot.igraph() calls the shape
  plot function once for all vertices (v = NULL), so a scalar vertex.size
  mismatched the coordinate matrix. Recycle the size with
  rep(..., length.out = nrow(coords)), as the built-in shapes do.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Use a denser make_full_graph(25) with larger labels for the
  vertex.label.repel example so the de-overlapping is clearly visible.
- Add an example of several self-loops on a single vertex, which igraph
  arranges in a flower-petal pattern.
- Drop the trailing sessionInfo() chunk.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
schochastics and others added 2 commits June 24, 2026 21:57
Remove the development-scaffolding tags from comments and test labels
(feature F1-F6, B-numbers, "phase N", and the pipeline "Stage N" labels),
keeping the descriptive prose. These referred to the planning notes and add
no value in the shipped code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- as_svg(): set/reset i.render_state$vertex_titles in the function's own frame
  instead of inside the i.with_renderer() block, where it relied on a subtle
  on.exit(add = TRUE) interaction across the forced-promise boundary. Clearer
  and more robust; behaviour unchanged.
- base renderer init_canvas(): use if/else instead of ifelse() for the scalar
  frame.plot fallback.
- reflow an aesthetic-table comment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@schochastics schochastics marked this pull request as draft June 24, 2026 20:05
@schochastics schochastics changed the title Refactor and extend plot.igraph(): renderer pipeline, new features, and a plotting article Refactor and extend plot.igraph(): renderer pipeline, and new features Jun 24, 2026
@github-actions

Copy link
Copy Markdown
Contributor

This is how benchmark results would change (along with a 95% confidence interval in relative change) if b069ab2 is merged into main:

  • ✔️as_adjacency_matrix: 734ms -> 736ms [-0.38%, +0.92%]
  • 🚀as_biadjacency_matrix: 743ms -> 738ms [-1.25%, -0.08%]
  • ✔️as_data_frame_both: 1.45ms -> 1.45ms [-0.79%, +1.16%]
  • ✔️as_long_data_frame: 3.88ms -> 3.85ms [-2.65%, +1.27%]
  • ✔️es_attr_filter: 2.6ms -> 2.62ms [-0.1%, +1.48%]
  • ✔️graph_from_adjacency_matrix: 115ms -> 116ms [-1%, +2.01%]
  • ❗🐌graph_from_data_frame: 3.33ms -> 3.39ms [+0.21%, +3.59%]
  • ✔️vs_attr_filter: 1.5ms -> 1.53ms [-1.21%, +4.62%]
  • ✔️vs_by_name: 957µs -> 960µs [-1.62%, +2.41%]
    Further explanation regarding interpretation and methodology can be found in the documentation.

schochastics and others added 2 commits June 25, 2026 09:55
Elbow and diagonal edges attached to vertices at the centre-to-centre
boundary point (the shape clip functions clip along the straight line between
centres, regardless of style). Since these styles route along an axis, their
first/last segment then started off the vertex's centre axis — very visible in
tree layouts, where a parent's stubs left from down-left/down-right of its
bottom and children were entered left/right of their top.

For elbow/diagonal edges, re-clip the endpoints along the dominant axis
(chosen from the vertex centres) so they attach at the top/bottom- or
left/right-centre of each vertex, and pass that axis into the path builders so
routing matches the attachment. Straight, arc and self-loop edges are
unchanged.

- i.elbow_path()/i.diagonal_path(): new `vertical` arg (NULL = infer as before).
- igraph.Arrows(): new `axis` arg, recycled and forwarded to the path builders.
- plot.igraph(): re-clip elbow/diagonal endpoints via new i.axis_clip_endpoints()
  (reuses the shape clip functions with axis-aligned synthetic segments) and
  pass the per-edge axis to igraph.Arrows.
- Tests: explicit-axis unit tests; an integration test asserting centre-axis
  attachment via the record renderer; re-blessed the elbow/diagonal/mixed
  snapshots (arc/straight snapshots unchanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

This is how benchmark results would change (along with a 95% confidence interval in relative change) if 70e36ff is merged into main:

  • ✔️as_adjacency_matrix: 833ms -> 824ms [-3.04%, +0.68%]
  • ✔️as_biadjacency_matrix: 836ms -> 840ms [-1.24%, +2.23%]
  • ✔️as_data_frame_both: 1.58ms -> 1.57ms [-3.02%, +2.73%]
  • ✔️as_long_data_frame: 3.98ms -> 4.06ms [-1.69%, +5.32%]
  • ✔️es_attr_filter: 2.83ms -> 2.82ms [-2.12%, +1.41%]
  • ✔️graph_from_adjacency_matrix: 155ms -> 154ms [-2.17%, +1.24%]
  • ✔️graph_from_data_frame: 3.43ms -> 3.45ms [-0.51%, +1.59%]
  • ✔️vs_attr_filter: 1.65ms -> 1.65ms [-3.43%, +4.32%]
  • ✔️vs_by_name: 1.22ms -> 1.09ms [-28.39%, +5.97%]
    Further explanation regarding interpretation and methodology can be found in the documentation.

@github-actions

Copy link
Copy Markdown
Contributor

This is how benchmark results would change (along with a 95% confidence interval in relative change) if 8cbc440 is merged into main:

  • ✔️as_adjacency_matrix: 773ms -> 782ms [-0.78%, +3.06%]
  • ✔️as_biadjacency_matrix: 788ms -> 789ms [-1.02%, +1.17%]
  • ✔️as_data_frame_both: 1.55ms -> 1.61ms [-0.64%, +8.25%]
  • ✔️as_long_data_frame: 4.06ms -> 4.03ms [-3.74%, +2.21%]
  • ✔️es_attr_filter: 2.95ms -> 2.88ms [-9.3%, +4.23%]
  • ✔️graph_from_adjacency_matrix: 130ms -> 128ms [-3.62%, +0.49%]
  • ✔️graph_from_data_frame: 3.52ms -> 3.49ms [-4.6%, +2.75%]
  • ✔️vs_attr_filter: 1.69ms -> 1.71ms [-3.81%, +6.57%]
  • ✔️vs_by_name: 1.03ms -> 1.02ms [-6.27%, +3.53%]
    Further explanation regarding interpretation and methodology can be found in the documentation.

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.

1 participant