From 66c63f03e8a3cf8c2d7cfa62c5abed09ce25cdb8 Mon Sep 17 00:00:00 2001 From: David Schoch Date: Thu, 23 Jul 2026 22:16:47 +0200 Subject: [PATCH] Phase 5: native KDEEB hammer bundling, drop Python dependency Reimplement edge_bundle_hammer() in C++ (src/kdeeb.cpp) as kernel density estimation edge bundling (Hurter, Ersoy & Telea 2012): sample edges into points, estimate a density field on a grid, advect points up the density gradient, smooth and resample, shrinking the bandwidth each iteration. This removes the reticulate/datashader dependency entirely: reticulate is dropped from Imports, and the .onLoad Python config, shader_env, and install_bundle_py() are removed. Bundling output differs from the old datashader-backed version. Co-Authored-By: Claude Opus 4.8 (1M context) --- DESCRIPTION | 3 +- NAMESPACE | 1 - NEWS.md | 1 + R/RcppExports.R | 4 + R/bundle_hammer.R | 93 +++++++------------ man/edge_bundle_hammer.Rd | 50 +++++++++-- man/install_bundle_py.Rd | 19 ---- src/RcppExports.cpp | 19 ++++ src/kdeeb.cpp | 170 +++++++++++++++++++++++++++++++++++ tests/testthat/test-hammer.R | 25 ++++++ 10 files changed, 292 insertions(+), 93 deletions(-) delete mode 100644 man/install_bundle_py.Rd create mode 100644 src/kdeeb.cpp create mode 100644 tests/testthat/test-hammer.R diff --git a/DESCRIPTION b/DESCRIPTION index 642e91d..e0d2964 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -21,10 +21,9 @@ LazyData: true Roxygen: list(markdown = TRUE) LinkingTo: Rcpp -Imports: +Imports: Rcpp, igraph, - reticulate, interp Depends: R (>= 3.5) diff --git a/NAMESPACE b/NAMESPACE index 3639f42..ed04bda 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -9,7 +9,6 @@ export(edge_bundle_force) export(edge_bundle_hammer) export(edge_bundle_path) export(edge_bundle_stub) -export(install_bundle_py) export(metro_multicriteria) export(tnss_dummies) export(tnss_smooth) diff --git a/NEWS.md b/NEWS.md index dccf411..a77962d 100644 --- a/NEWS.md +++ b/NEWS.md @@ -6,6 +6,7 @@ * fixed `edge_bundle_stub()`: the angular grouping was broken (wrong circular-gap handling and a bundle-size cap that summed cluster ids instead of counting edges) and vertical edges could produce `NaN`. Bundling output changes as a result * fixed `edge_bundle_path()`: the path-length used in the distortion test was computed along the wrong vertices, so edges were essentially never routed. Bundling output changes as a result * added divided edge bundling for directed graphs via `edge_bundle_force(directed = TRUE)` (Selassie et al. 2011): edges running in opposite directions are kept in separate lanes +* `edge_bundle_hammer()` is now a native C++ implementation of KDE edge bundling (Hurter et al. 2012) and no longer depends on Python/`reticulate`/datashader. `reticulate` was dropped from Imports and `install_bundle_py()` was removed. Bundling output differs from the datashader-based version # edgebundle 0.4.2 diff --git a/R/RcppExports.R b/R/RcppExports.R index de74ee2..a0fcce2 100644 --- a/R/RcppExports.R +++ b/R/RcppExports.R @@ -9,6 +9,10 @@ force_bundle_iter <- function(edges_xy, K, C, P, P_rate, S, I, I_rate, compatibi .Call(`_edgebundle_force_bundle_iter`, edges_xy, K, C, P, P_rate, S, I, I_rate, compatibility_threshold, eps) } +kdeeb_iter <- function(edges_xy, npoints, niter, bw, decay, grid, step, smooth_passes) { + .Call(`_edgebundle_kdeeb_iter`, edges_xy, npoints, niter, bw, decay, grid, step, smooth_passes) +} + criterion_angular_resolution <- function(adj, xy) { .Call(`_edgebundle_criterion_angular_resolution`, adj, xy) } diff --git a/R/bundle_hammer.R b/R/bundle_hammer.R index 0b67f7d..3682a76 100644 --- a/R/bundle_hammer.R +++ b/R/bundle_hammer.R @@ -1,74 +1,41 @@ #' @title hammer edge bundling -#' @description Implements the hammer edge bundling by Ian Calvert. -#' @details This function only wraps existing python code from the datashader library. Original code can be found at https://gitlab.com/ianjcalvert/edgehammer. -#' Datashader is a huge library with a lot of dependencies, so think twice if you want to install it just for edge bundling. -#' Check https://datashader.org/user_guide/Networks.html for help concerning parameters bw and decay. -#' To install all dependencies, use [install_bundle_py]. +#' @description Implements hammer edge bundling via kernel density estimation (KDEEB). +#' @details Native re-implementation of the KDE-based bundling behind the +#' datashader "hammer" bundler, following Hurter, Ersoy and Telea (2012). Earlier +#' versions of this function wrapped datashader through `reticulate`; it now runs +#' entirely in C++ with no Python dependency. Edges are sampled into points, a +#' density field is estimated on a grid, points are advected up the density +#' gradient, and the bandwidth shrinks each iteration so bundles emerge along +#' density ridges. #' @param object a graph object (igraph/network/tbl_graph) #' @param xy coordinates of vertices -#' @param bw bandwidth parameter -#' @param decay decay parameter +#' @param bw initial bandwidth (fraction of the layout extent) +#' @param decay bandwidth decay per iteration (0-1) +#' @param npoints number of points sampled per edge +#' @param iterations number of bundling iterations +#' @param grid resolution of the density grid +#' @param step advection step size (multiple of the bandwidth) +#' @param smooth number of smoothing passes per iteration #' @return data.frame containing the bundled edges #' @author David Schoch #' @details see [online](https://github.com/schochastics/edgebundle) for plotting tips #' @seealso [edge_bundle_force],[edge_bundle_stub], [edge_bundle_path] +#' @references +#' Hurter, Christophe, Ozan Ersoy, and Alexandru Telea. "Graph Bundling by Kernel Density Estimation." Computer Graphics Forum 31, no. 3 (2012): 865-874. +#' @examples +#' library(igraph) +#' g <- graph_from_edgelist( +#' matrix(c(1, 12, 2, 11, 3, 10, 4, 9, 5, 8, 6, 7), ncol = 2, byrow = TRUE), FALSE +#' ) +#' xy <- cbind(c(rep(0, 6), rep(1, 6)), c(1:6, 1:6)) +#' edge_bundle_hammer(g, xy) #' @export +edge_bundle_hammer <- function(object, xy, bw = 0.05, decay = 0.7, npoints = 50, + iterations = 12, grid = 256, step = 0.6, smooth = 1) { + edges_xy <- .bundle_inputs(object, xy)$exy + m <- nrow(edges_xy) -edge_bundle_hammer <- function(object, xy, bw = 0.05, decay = 0.7) { - if (!requireNamespace("reticulate", quietly = TRUE)) { - stop("The `reticulate` package is required for this functionality") - } - if (any(class(object) == "igraph")) { - if (!requireNamespace("igraph", quietly = TRUE)) { - stop("The `igraph` package is required for this functionality") - } - nodes <- data.frame(name = paste0("node", 0:(igraph::vcount(object) - 1)), x = xy[, 1], y = xy[, 2]) - el <- igraph::as_edgelist(object, names = FALSE) - el1 <- data.frame(source = el[, 1] - 1, target = el[, 2] - 1) - } else if (any(class(object) == "tbl_graph")) { - if (!requireNamespace("tidygraph", quietly = TRUE)) { - stop("The `tidygraph` package is required for this functionality") - } - object <- tidygraph::as.igraph(object) - nodes <- data.frame(name = paste0("node", 0:(igraph::vcount(object) - 1)), x = xy[, 1], y = xy[, 2]) - el <- igraph::as_edgelist(object, names = FALSE) - el1 <- data.frame(source = el[, 1] - 1, target = el[, 2] - 1) - } else if (any(class(object) == "network")) { - nodes <- data.frame(name = paste0("node", 0:(network::get.network.attribute(object, "n") - 1)), x = xy[, 1], y = xy[, 2]) - el <- network::as.edgelist(object) - el1 <- data.frame(source = el[, 1] - 1, target = el[, 2] - 1) - } else { - stop("only `igraph`, `network` or `tbl_graph` objects supported.") - } - data_bundle <- shader_env$datashader_bundling$hammer_bundle(nodes, el1, initial_bandwidth = bw, decay = decay) - data_bundle$group <- is.na(data_bundle$y) + 0 - data_bundle$group <- cumsum(data_bundle$group) + 1 - data_bundle <- data_bundle[!is.na(data_bundle$y), ] - data_bundle$index <- unlist(sapply(table(data_bundle$group), function(x) seq(0, 1, length.out = x))) - data_bundle[, c("x", "y", "index", "group")] -} - -#' @title install python dependencies for hammer bundling -#' @description install datashader and scikit-image -#' @param method Installation method (by default, "auto" automatically finds a -#' method that will work in the local environment, but note that the -#' "virtualenv" method is not available on Windows) -#' @param conda Path to conda executable (or "auto" to find conda using the PATH -#' and other conventional install locations) -#' @export -#' -install_bundle_py <- function(method = "auto", conda = "auto") { - if (!requireNamespace("reticulate", quietly = TRUE)) { - stop("The `reticulate` package is required for this functionality") - } - reticulate::py_install("datashader", method = method, conda = conda, pip = TRUE) - reticulate::py_install("scikit-image", method = method, conda = conda, pip = TRUE) -} - -# Environment for globals -shader_env <- new.env(parent = emptyenv()) + elist <- kdeeb_iter(edges_xy, npoints, iterations, bw, decay, grid, step, smooth) -.onLoad <- function(libname, pkgname) { - reticulate::configure_environment(pkgname) - assign("datashader_bundling", reticulate::import("datashader.bundling", delay_load = TRUE), shader_env) + .as_bundle_df(do.call("rbind", elist), m, npoints) } diff --git a/man/edge_bundle_hammer.Rd b/man/edge_bundle_hammer.Rd index 9ab2760..2e6493c 100644 --- a/man/edge_bundle_hammer.Rd +++ b/man/edge_bundle_hammer.Rd @@ -4,31 +4,65 @@ \alias{edge_bundle_hammer} \title{hammer edge bundling} \usage{ -edge_bundle_hammer(object, xy, bw = 0.05, decay = 0.7) +edge_bundle_hammer( + object, + xy, + bw = 0.05, + decay = 0.7, + npoints = 50, + iterations = 12, + grid = 256, + step = 0.6, + smooth = 1 +) } \arguments{ \item{object}{a graph object (igraph/network/tbl_graph)} \item{xy}{coordinates of vertices} -\item{bw}{bandwidth parameter} +\item{bw}{initial bandwidth (fraction of the layout extent)} -\item{decay}{decay parameter} +\item{decay}{bandwidth decay per iteration (0-1)} + +\item{npoints}{number of points sampled per edge} + +\item{iterations}{number of bundling iterations} + +\item{grid}{resolution of the density grid} + +\item{step}{advection step size (multiple of the bandwidth)} + +\item{smooth}{number of smoothing passes per iteration} } \value{ data.frame containing the bundled edges } \description{ -Implements the hammer edge bundling by Ian Calvert. +Implements hammer edge bundling via kernel density estimation (KDEEB). } \details{ -This function only wraps existing python code from the datashader library. Original code can be found at https://gitlab.com/ianjcalvert/edgehammer. -Datashader is a huge library with a lot of dependencies, so think twice if you want to install it just for edge bundling. -Check https://datashader.org/user_guide/Networks.html for help concerning parameters bw and decay. -To install all dependencies, use \link{install_bundle_py}. +Native re-implementation of the KDE-based bundling behind the +datashader "hammer" bundler, following Hurter, Ersoy and Telea (2012). Earlier +versions of this function wrapped datashader through \code{reticulate}; it now runs +entirely in C++ with no Python dependency. Edges are sampled into points, a +density field is estimated on a grid, points are advected up the density +gradient, and the bandwidth shrinks each iteration so bundles emerge along +density ridges. see \href{https://github.com/schochastics/edgebundle}{online} for plotting tips } +\examples{ +library(igraph) +g <- graph_from_edgelist( + matrix(c(1, 12, 2, 11, 3, 10, 4, 9, 5, 8, 6, 7), ncol = 2, byrow = TRUE), FALSE +) +xy <- cbind(c(rep(0, 6), rep(1, 6)), c(1:6, 1:6)) +edge_bundle_hammer(g, xy) +} +\references{ +Hurter, Christophe, Ozan Ersoy, and Alexandru Telea. "Graph Bundling by Kernel Density Estimation." Computer Graphics Forum 31, no. 3 (2012): 865-874. +} \seealso{ \link{edge_bundle_force},\link{edge_bundle_stub}, \link{edge_bundle_path} } diff --git a/man/install_bundle_py.Rd b/man/install_bundle_py.Rd deleted file mode 100644 index 2d7a08c..0000000 --- a/man/install_bundle_py.Rd +++ /dev/null @@ -1,19 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/bundle_hammer.R -\name{install_bundle_py} -\alias{install_bundle_py} -\title{install python dependencies for hammer bundling} -\usage{ -install_bundle_py(method = "auto", conda = "auto") -} -\arguments{ -\item{method}{Installation method (by default, "auto" automatically finds a -method that will work in the local environment, but note that the -"virtualenv" method is not available on Windows)} - -\item{conda}{Path to conda executable (or "auto" to find conda using the PATH -and other conventional install locations)} -} -\description{ -install datashader and scikit-image -} diff --git a/src/RcppExports.cpp b/src/RcppExports.cpp index 5d4d42a..c405198 100644 --- a/src/RcppExports.cpp +++ b/src/RcppExports.cpp @@ -53,6 +53,24 @@ BEGIN_RCPP return rcpp_result_gen; END_RCPP } +// kdeeb_iter +List kdeeb_iter(NumericMatrix edges_xy, int npoints, int niter, double bw, double decay, int grid, double step, int smooth_passes); +RcppExport SEXP _edgebundle_kdeeb_iter(SEXP edges_xySEXP, SEXP npointsSEXP, SEXP niterSEXP, SEXP bwSEXP, SEXP decaySEXP, SEXP gridSEXP, SEXP stepSEXP, SEXP smooth_passesSEXP) { +BEGIN_RCPP + Rcpp::RObject rcpp_result_gen; + Rcpp::RNGScope rcpp_rngScope_gen; + Rcpp::traits::input_parameter< NumericMatrix >::type edges_xy(edges_xySEXP); + Rcpp::traits::input_parameter< int >::type npoints(npointsSEXP); + Rcpp::traits::input_parameter< int >::type niter(niterSEXP); + Rcpp::traits::input_parameter< double >::type bw(bwSEXP); + Rcpp::traits::input_parameter< double >::type decay(decaySEXP); + Rcpp::traits::input_parameter< int >::type grid(gridSEXP); + Rcpp::traits::input_parameter< double >::type step(stepSEXP); + Rcpp::traits::input_parameter< int >::type smooth_passes(smooth_passesSEXP); + rcpp_result_gen = Rcpp::wrap(kdeeb_iter(edges_xy, npoints, niter, bw, decay, grid, step, smooth_passes)); + return rcpp_result_gen; +END_RCPP +} // criterion_angular_resolution double criterion_angular_resolution(List adj, NumericMatrix xy); RcppExport SEXP _edgebundle_criterion_angular_resolution(SEXP adjSEXP, SEXP xySEXP) { @@ -135,6 +153,7 @@ END_RCPP static const R_CallMethodDef CallEntries[] = { {"_edgebundle_divided_bundle_iter", (DL_FUNC) &_edgebundle_divided_bundle_iter, 13}, {"_edgebundle_force_bundle_iter", (DL_FUNC) &_edgebundle_force_bundle_iter, 10}, + {"_edgebundle_kdeeb_iter", (DL_FUNC) &_edgebundle_kdeeb_iter, 8}, {"_edgebundle_criterion_angular_resolution", (DL_FUNC) &_edgebundle_criterion_angular_resolution, 2}, {"_edgebundle_criterion_edge_length", (DL_FUNC) &_edgebundle_criterion_edge_length, 3}, {"_edgebundle_criterion_balanced_edge_length", (DL_FUNC) &_edgebundle_criterion_balanced_edge_length, 2}, diff --git a/src/kdeeb.cpp b/src/kdeeb.cpp new file mode 100644 index 0000000..7194c62 --- /dev/null +++ b/src/kdeeb.cpp @@ -0,0 +1,170 @@ +// Kernel-density-estimation edge bundling (KDEEB). +// Hurter, Ersoy, Telea (2012) "Graph Bundling by Kernel Density Estimation". +// This is the algorithm behind datashader's "hammer" bundling; implemented +// natively here so no Python/datashader dependency is needed. +#include +using namespace Rcpp; + +typedef std::vector> Mat; + +static void gaussian_blur(std::vector &grid, int n, double sigma) { + if (sigma < 0.5) return; + int r = (int)std::ceil(3.0 * sigma); + std::vector kern(2 * r + 1); + double ksum = 0.0; + for (int i = -r; i <= r; ++i) { + kern[i + r] = std::exp(-(i * i) / (2.0 * sigma * sigma)); + ksum += kern[i + r]; + } + for (double &k : kern) k /= ksum; + + std::vector tmp(n * n, 0.0); + // horizontal + for (int y = 0; y < n; ++y) { + for (int x = 0; x < n; ++x) { + double acc = 0.0; + for (int i = -r; i <= r; ++i) { + int xx = std::min(std::max(x + i, 0), n - 1); + acc += grid[y * n + xx] * kern[i + r]; + } + tmp[y * n + x] = acc; + } + } + // vertical + for (int y = 0; y < n; ++y) { + for (int x = 0; x < n; ++x) { + double acc = 0.0; + for (int i = -r; i <= r; ++i) { + int yy = std::min(std::max(y + i, 0), n - 1); + acc += tmp[yy * n + x] * kern[i + r]; + } + grid[y * n + x] = acc; + } + } +} + +// bilinear sample of a grid at continuous cell coordinates +static double sample(const std::vector &grid, int n, double cx, double cy) { + int x0 = (int)std::floor(cx), y0 = (int)std::floor(cy); + double fx = cx - x0, fy = cy - y0; + int x1 = std::min(x0 + 1, n - 1), y1 = std::min(y0 + 1, n - 1); + x0 = std::min(std::max(x0, 0), n - 1); + y0 = std::min(std::max(y0, 0), n - 1); + return grid[y0 * n + x0] * (1 - fx) * (1 - fy) + grid[y0 * n + x1] * fx * (1 - fy) + + grid[y1 * n + x0] * (1 - fx) * fy + grid[y1 * n + x1] * fx * fy; +} + +// resample a polyline to `npoints` equally spaced points (endpoints preserved) +static void resample(std::vector &px, std::vector &py, int npoints) { + int k = px.size(); + std::vector cl(k, 0.0); + for (int i = 1; i < k; ++i) { + double dx = px[i] - px[i - 1], dy = py[i] - py[i - 1]; + cl[i] = cl[i - 1] + std::sqrt(dx * dx + dy * dy); + } + double total = cl[k - 1]; + std::vector nx(npoints), ny(npoints); + if (total <= 0) { + for (int i = 0; i < npoints; ++i) { + nx[i] = px[0]; + ny[i] = py[0]; + } + } else { + int seg = 0; + for (int i = 0; i < npoints; ++i) { + double target = total * i / (npoints - 1); + while (seg < k - 2 && cl[seg + 1] < target) seg++; + double segd = cl[seg + 1] - cl[seg]; + double t = segd > 0 ? (target - cl[seg]) / segd : 0.0; + nx[i] = px[seg] + t * (px[seg + 1] - px[seg]); + ny[i] = py[seg] + t * (py[seg + 1] - py[seg]); + } + } + px = nx; + py = ny; +} + +// [[Rcpp::export]] +List kdeeb_iter(NumericMatrix edges_xy, int npoints, int niter, double bw, + double decay, int grid, double step, int smooth_passes) { + int m = edges_xy.rows(); + + double xmin = edges_xy(0, 0), xmax = xmin, ymin = edges_xy(0, 1), ymax = ymin; + for (int e = 0; e < m; ++e) { + for (int c = 0; c < 4; c += 2) { + xmin = std::min(xmin, edges_xy(e, c)); + xmax = std::max(xmax, edges_xy(e, c)); + ymin = std::min(ymin, edges_xy(e, c + 1)); + ymax = std::max(ymax, edges_xy(e, c + 1)); + } + } + double span = std::max(xmax - xmin, ymax - ymin); + if (span <= 0) span = 1.0; + double pad = 0.05; + + // initial polylines in normalized [pad, 1-pad] coordinates + Mat X(m), Y(m); + for (int e = 0; e < m; ++e) { + double x0 = (edges_xy(e, 0) - xmin) / span * (1 - 2 * pad) + pad; + double y0 = (edges_xy(e, 1) - ymin) / span * (1 - 2 * pad) + pad; + double x1 = (edges_xy(e, 2) - xmin) / span * (1 - 2 * pad) + pad; + double y1 = (edges_xy(e, 3) - ymin) / span * (1 - 2 * pad) + pad; + X[e].resize(npoints); + Y[e].resize(npoints); + for (int i = 0; i < npoints; ++i) { + double t = (double)i / (npoints - 1); + X[e][i] = x0 + t * (x1 - x0); + Y[e][i] = y0 + t * (y1 - y0); + } + } + + for (int it = 0; it < niter; ++it) { + double h = bw * std::pow(decay, it); + double sigma_px = h * grid; + + std::vector dens(grid * grid, 0.0); + for (int e = 0; e < m; ++e) { + for (int i = 0; i < npoints; ++i) { + int gx = std::min(std::max((int)std::floor(X[e][i] * (grid - 1)), 0), grid - 1); + int gy = std::min(std::max((int)std::floor(Y[e][i] * (grid - 1)), 0), grid - 1); + dens[gy * grid + gx] += 1.0; + } + } + gaussian_blur(dens, grid, sigma_px); + + for (int e = 0; e < m; ++e) { + for (int i = 1; i < npoints - 1; ++i) { + double cx = X[e][i] * (grid - 1), cy = Y[e][i] * (grid - 1); + double gxp = sample(dens, grid, cx + 1, cy) - sample(dens, grid, cx - 1, cy); + double gyp = sample(dens, grid, cx, cy + 1) - sample(dens, grid, cx, cy - 1); + double gn = std::sqrt(gxp * gxp + gyp * gyp); + if (gn > 1e-12) { + X[e][i] += step * h * gxp / gn; + Y[e][i] += step * h * gyp / gn; + } + } + } + + for (int e = 0; e < m; ++e) { + for (int pass = 0; pass < smooth_passes; ++pass) { + std::vector sx = X[e], sy = Y[e]; + for (int i = 1; i < npoints - 1; ++i) { + X[e][i] = 0.5 * sx[i] + 0.25 * (sx[i - 1] + sx[i + 1]); + Y[e][i] = 0.5 * sy[i] + 0.25 * (sy[i - 1] + sy[i + 1]); + } + } + resample(X[e], Y[e], npoints); + } + } + + List out(m); + for (int e = 0; e < m; ++e) { + NumericMatrix mat(npoints, 2); + for (int i = 0; i < npoints; ++i) { + mat(i, 0) = (X[e][i] - pad) / (1 - 2 * pad) * span + xmin; + mat(i, 1) = (Y[e][i] - pad) / (1 - 2 * pad) * span + ymin; + } + out[e] = mat; + } + return out; +} diff --git a/tests/testthat/test-hammer.R b/tests/testthat/test-hammer.R new file mode 100644 index 0000000..527b556 --- /dev/null +++ b/tests/testthat/test-hammer.R @@ -0,0 +1,25 @@ +test_that("edge_bundle_hammer returns the canonical data frame with no Python", { + fx <- force_fixture() + res <- edge_bundle_hammer(fx$g, fx$xy) + expect_s3_class(res, "data.frame") + expect_named(res, c("x", "y", "index", "group")) + expect_equal(sort(unique(res$group)), 1:6) + expect_equal(unname(unique(table(res$group))), 50L) + expect_true(all(res$index >= 0 & res$index <= 1)) + expect_true(all(is.finite(res$x)) && all(is.finite(res$y))) +}) + +test_that("hammer bundling pulls nearby parallel edges together", { + xy <- rbind(c(0, -1.5), c(10, -1.5), c(0, -0.5), c(10, -0.5), + c(0, 0.5), c(10, 0.5), c(0, 1.5), c(10, 1.5)) + g <- igraph::graph_from_edgelist( + matrix(c(1, 2, 3, 4, 5, 6, 7, 8), ncol = 2, byrow = TRUE), FALSE + ) + res <- edge_bundle_hammer(g, xy) + mids <- tapply(seq_len(nrow(res)), res$group, function(ix) { + e <- res[ix, ] + e$y[round(nrow(e) / 2)] + }) + expect_lt(diff(range(mids)), 3) # initial spread is 3 + expect_true(all(is.finite(res$y))) +})