library(igraph)
library(networkdata)
library(visNetwork)
library(networkD3)
library(threejs)
library(g6R)13 Interactive Visualization
The previous chapters focused on creating static network plots with ggraph. Static visualizations are essential for publications and reports, but interactive visualizations open up a different mode of exploration: users can pan across the canvas, zoom into dense regions, hover over nodes for details, and drag elements to untangle complex structures. This chapter surveys the landscape of interactive network visualization in R and then provides a thorough introduction to g6R, a modern and feature-rich package for building interactive network graphics.
13.1 Packages Needed for this Chapter
The packages visNetwork, networkD3, and threejs only appear briefly in the overview. The remainder of the chapter focuses on g6R.
13.2 Data Preparation
We use the Florentine Families marriage network, introduced in earlier chapters. With 16 families and 20 ties it is small enough that every label, hull, and bubble in this chapter renders without clutter, and it comes with a wealth attribute that gives us a meaningful categorical grouping for later plugin demos.
The Pucci family is isolated in the marriage network, so we restrict to the connected component of 15 families and bucket the wealth attribute into three tiers.
data("flo_marriage")
flo <- subgraph(
flo_marriage,
components(flo_marriage)$membership ==
which.max(components(flo_marriage)$csize)
)
# Wealth tiers: grouping used for color, legend, hulls, bubble sets
V(flo)$wealth_tier <- as.character(cut(
V(flo)$wealth,
breaks = quantile(V(flo)$wealth, probs = c(0, 1 / 3, 2 / 3, 1)),
labels = c("low", "mid", "high"),
include.lowest = TRUE
))
# Degree for node sizes
V(flo)$degree <- degree(flo)
palette3 <- c(low = "#1A5878", mid = "#AD8941", high = "#C44237")13.3 Interactive Network Visualization Tools in R
There exist several R packages that can produce interactive network visualizations, each wrapping a different JavaScript library. We briefly survey three popular options before diving into g6R.
13.3.1 visNetwork
The visNetwork package wraps the vis.js library and offers a broad toolkit for interactive networks. Converting an igraph object requires mapping a few vertex attributes to the column names that visNetwork expects.
V(flo)$label <- V(flo)$name
V(flo)$group <- V(flo)$wealth_tier
V(flo)$value <- V(flo)$degree
vis_data <- toVisNetworkData(flo)
visNetwork(vis_data$nodes, vis_data$edges, width = "100%", height = "400px") |>
visOptions(highlightNearest = TRUE)visNetwork.
Out of the box, visNetwork provides draggable nodes, zoom, and hover highlighting (see Figure 13.1). It is well suited for quick interactive previews that do not require much customization.
13.3.2 networkD3
The networkD3 package wraps D3.js and produces force-directed layouts (see Figure 13.2).
d3_data <- igraph_to_networkD3(flo, group = V(flo)$wealth_tier)
forceNetwork(
Links = d3_data$links,
Nodes = d3_data$nodes,
Source = "source",
Target = "target",
NodeID = "name",
Group = "group",
opacity = 0.9,
zoom = TRUE,
fontSize = 12
)networkD3.
13.3.3 threejs
The threejs package renders networks in 3D using WebGL (see Figure 13.3). The result is visually striking, although 3D layouts can make it harder to judge distances and read labels.
graphjs(flo,
vertex.size = 0.3 + 0.2 * V(flo)$degree
)threejs.
Each of these packages has its place. visNetwork is mature and full-featured, networkD3 leverages the power of D3.js, and threejs offers 3D rendering. For the remainder of this chapter we focus on g6R, which provides the most comprehensive framework for interactive graph visualization in R, with a rich plugin system and high-performance rendering.
13.4 Getting Started with g6R
The g6R package is an R binding to AntV’s G6 graph visualization engine. It produces htmlwidgets that render in Quarto documents, the RStudio viewer, and Shiny applications.
13.4.1 g6_igraph() vs building a g6 graph explicitly
The package ships an g6_igraph() convenience function that converts an igraph object directly into a g6 widget. This is by far the quickest path to a rendered network and is a reasonable choice when all you need is a quick interactive preview. However, g6_igraph() hides the data structure that G6 actually consumes under the hood, and not every feature in the package reliably survives the conversion: plugins such as hull() and bubble_sets() reference nodes by id and are sensitive to how ids are encoded. Some per-node style keys are silently dropped depending on which igraph attributes are present and certain layout options do not pick up node metadata as expected. Readers who want to combine multiple plugins, per-element styling, and richer interaction quickly run into these rough edges.
For this reason, the rest of the chapter builds the inputs to g6() explicitly. We construct a list of nodes and a list of edges with g6_node() / g6_edge(), and pass those to g6(). It is slightly more verbose than g6_igraph(flo), but in return every plugin, behavior, and style property we demonstrate receives exactly the data structure it expects.
13.4.2 Building nodes and edges
A g6 node is a list with at minimum an id. It can also carry a data slot for attributes the reader may want to inspect or use in JavaScript callbacks, and a style slot for visual properties such as fill, size, labelText, stroke, and lineWidth. An edge is a list with source and target fields.
We use Map() to zip the igraph attributes into per-node and per-edge lists, and g6_nodes() / g6_edges() to assemble them.
nodes <- do.call(g6_nodes, unname(Map(
function(name, tier, deg) {
g6_node(
id = name,
data = list(wealth_tier = tier, degree = deg),
style = list(
labelText = name,
fill = palette3[[tier]],
size = 14 + 2 * deg,
stroke = "#555555",
lineWidth = 1
)
)
},
V(flo)$name, V(flo)$wealth_tier, V(flo)$degree
)))
edges_df <- igraph::as_data_frame(flo, what = "edges")
edges <- do.call(g6_edges, unname(Map(
function(src, tgt) {
g6_edge(
source = src,
target = tgt,
style = list(stroke = "#cccccc", lineWidth = 1)
)
},
edges_df$from, edges_df$to
)))Node size is scaled from degree, fill comes from the wealth tier, and the family name is used both as id and as label. Edges get a uniform light-grey stroke.
13.4.3 A first interactive network
With nodes and edges in hand, the shortest viable g6R pipeline is a single g6() call piped into a layout. Figure 13.4 shows the result: the force simulation animates as the graph loads, but no user interactions are wired up yet. So after the layout settles, no further interactions are possible. These are added through behaviors, which are covered in their own section later. g6_options() is used here to make the canvas fill the available space (autoFit = "view") and resize responsively (autoResize = TRUE).
g6(nodes, edges) |>
g6_layout() |>
g6_options(autoFit = "view", autoResize = TRUE)g6R using just a layout and no behaviors.
13.4.4 The g6R workflow
Customization in g6R follows a pipe-based workflow. Starting from the initial graph, you chain together functions that each control a different aspect of the visualization:
g6(nodes, edges) |>
g6_layout(...) |> # spatial arrangement
g6_options(...) |> # node/edge styling defaults
g6_behaviors(...) |> # user interactions
g6_plugins(...) # add-on featuresEach of these building blocks is covered in its own section below.
13.5 Layouts
Layout algorithms determine where nodes are placed on the canvas. Unlike the static layouts computed by igraph or graphlayouts, the layouts in g6R run client-side in the browser. Force-directed layouts animate as nodes settle into position, making the layout process itself part of the interactive experience.
The default layout in g6R is d3_force_layout(), which simulates physical forces to position nodes: linked nodes attract each other while all nodes repel, producing a layout that tends to place densely connected groups close together. This is the layout used in Figure 13.4.
Another force-directed option is force_atlas2_layout(), a variant of the ForceAtlas2 algorithm popularized by Gephi. It produces a more compact layout with less overlap, and the preventOverlap option can be set to TRUE to further reduce node collisions at the cost of a longer layout time. For small networks like the Florentine families the difference is negligible. In Figure 13.5 we also set kr = 20 to increase the strength of repulsion and spread the nodes out a bit more.
g6(nodes, edges) |>
g6_layout(force_atlas2_layout(preventOverlap = TRUE, kr = 20)) |>
g6_options(autoFit = "view", autoResize = TRUE)force_atlas2 layout.
The package implements several other layout algorithms, including concentric_layout() for ring-based arrangements and dendrogram_layout() for tree graphs.
13.6 Styling Nodes and Edges
There are two complementary ways to control the appearance of nodes and edges. Per-element styles, set in the style slot of each node or edge, give every element its own color, size, and label as we did in the data preparation above. Global defaults, set with g6_options(), apply to all elements and can be overridden by per-element styles.
13.6.1 Per-element styling
Because the nodes and edges lists we built already carry a style slot for each element, any change to family-specific appearance lives right next to the data. The next code block keeps all the elements in place but switches to a thicker border on every node (see Figure 13.6).
nodes_bold <- do.call(g6_nodes, unname(Map(
function(name, tier, deg) {
g6_node(
id = name,
data = list(wealth_tier = tier, degree = deg),
style = list(
labelText = name,
fill = palette3[[tier]],
size = 14 + 2 * deg,
stroke = "#222222",
lineWidth = 2.5
)
)
},
V(flo)$name, V(flo)$wealth_tier, V(flo)$degree
)))
g6(nodes_bold, edges) |>
g6_layout(force_atlas2_layout(preventOverlap = TRUE, kr = 20)) |>
g6_options(autoFit = "view", autoResize = TRUE)13.6.2 Global defaults with g6_options()
For styling that applies uniformly to every element, g6_options() is cleaner than touching each node. It takes nested option builders like node_options(), edge_options(), and the corresponding *_style_options() helpers, that mirror G6’s global style configuration. In Figure 13.7 we shrink the label font, move labels below the node, and thin out the edge strokes globally.
g6(nodes, edges) |>
g6_layout(force_atlas2_layout(preventOverlap = TRUE, kr = 20)) |>
g6_options(
node = node_options(
style = node_style_options(
labelFontSize = 11,
labelPlacement = "bottom"
)
),
edge = edge_options(
style = edge_style_options(
stroke = "#bbbbbb",
lineWidth = 0.8
)
),
autoFit = "view",
autoResize = TRUE
)g6_options().
13.7 Behaviors
Behaviors define how users can interact with the visualization. They are added through g6_behaviors() and can be mixed freely.
13.7.2 Element interaction
Beyond canvas navigation, users can interact with individual elements. drag_element_force() lets you reposition nodes while the force simulation adjusts around them. Setting fixed = TRUE keeps a node where you drop it rather than letting the simulation pull it back. hover_activate() highlights a node and its direct neighbours on mouseover, and click_select() enables selection, with multiple = TRUE allowing shift-click to accumulate a selection set.
g6(nodes, edges) |>
g6_layout() |>
g6_behaviors(
drag_element_force(fixed = TRUE),
hover_activate(),
click_select(multiple = TRUE)
)In Figure 13.9, try hovering over a node to see its neighbourhood highlighted, or shift-click to select multiple families.
13.7.3 Lasso and brush selection
For selecting groups of nodes, g6R provides two area-selection tools. brush_select() draws a rectangular selection box, while lasso_select() allows freehand drawing around the nodes of interest. Figure 13.10 enables the lasso selection and Figure 13.11 enables the brush selection, both by holding down shift while dragging on the canvas.
g6(nodes, edges) |>
g6_layout() |>
g6_behaviors(
lasso_select()
)g6(nodes, edges) |>
g6_layout() |>
g6_behaviors(
brush_select()
)13.8 Plugins
Plugins add features on top of the base visualization. They are activated through g6_plugins() and can be combined freely. The two plugins that make g6R stand out as a graph-visualization tool, hulls and bubble sets, come first.
13.8.1 Visual grouping: hulls
In Figure 12.1 of the previous chapter, we used geom_mark_hull() from ggforce to highlight clusters in a static plot. The hull() plugin in g6R is the interactive analogue: it draws a convex polygon around a named set of nodes and redraws as the nodes move. The members argument is a character vector of node ids, which is where the explicit list-based construction pays off. We can simply split the family names by wealth tier.
Figure 13.12 uses the "drag-element" behavior (which updates node positions directly) rather than drag_element_force(), and sets animation = FALSE. Under this combination the hull reliably redraws.
tier_members <- split(V(flo)$name, V(flo)$wealth_tier)
g6(nodes, edges) |>
g6_layout() |>
g6_behaviors("drag-element") |>
g6_options(
animation = FALSE
) |>
g6_plugins(
hull(
key = "hull-low", members = tier_members$low,
fill = palette3[["low"]], stroke = palette3[["low"]]
),
hull(
key = "hull-mid", members = tier_members$mid,
fill = palette3[["mid"]], stroke = palette3[["mid"]]
),
hull(
key = "hull-high", members = tier_members$high,
fill = palette3[["high"]], stroke = palette3[["high"]]
)
)13.8.2 Visual grouping: bubble sets
The bubble_sets() plugin offers an alternative visual style. Instead of sharp convex hulls, it wraps groups in smooth, rounded contours that can overlap organically, as shown in Figure 13.13. This is often more legible than hulls when groups share members or sit close to each other on the canvas. As with the hull plugin, the bubble outline redraws cleanly under the "drag-element" behavior with animation = FALSE.
g6(nodes, edges) |>
g6_layout() |>
g6_behaviors("drag-element") |>
g6_options(
animation = FALSE
) |>
g6_plugins(
bubble_sets(
key = "bs-low", members = tier_members$low,
fill = palette3[["low"]], stroke = palette3[["low"]]
),
bubble_sets(
key = "bs-mid", members = tier_members$mid,
fill = palette3[["mid"]], stroke = palette3[["mid"]]
),
bubble_sets(
key = "bs-high", members = tier_members$high,
fill = palette3[["high"]], stroke = palette3[["high"]]
)
)13.8.3 Tooltips
Tooltips display information when the user hovers over a node. The default tooltip only shows the node’s id, so to surface the attributes we stored in the data slot earlier we pass a getContent callback to tooltips(). The callback is a small JavaScript function, wrapped with JS() that receives the hovered items and returns the HTML to render. Hovering over a family in Figure 13.14 now shows its name, wealth tier, and degree.
tooltip_content <- JS(
"(event, items) => {
let result = ``;
items.forEach((item) => {
result += `<h4>${item.id}</h4>`;
result += `<p>wealth tier: ${item.data.wealth_tier}</p>`;
result += `<p>degree: ${item.data.degree}</p>`;
});
return result;
}"
)
g6(nodes, edges) |>
g6_layout() |>
g6_plugins(tooltips(trigger = "click",getContent = tooltip_content))13.8.4 Legend
The legend plugin reads a field name from node data and draws a legend for it directly on the canvas. In Figure 13.15 we point it at wealth_tier, which matches the color split used by the hulls and bubbles above.
g6(nodes, edges) |>
g6_layout() |>
g6_plugins(legend(nodeField = "wealth_tier", showTitle = TRUE, titleText = "Wealth Tier", position = "top"))13.8.5 Fisheye lens
The fisheye plugin applies a focus-plus-context distortion: the area under the cursor is magnified while the rest of the graph remains visible at a reduced scale. On the 15-node graph in Figure 13.16 it is more of a curiosity, but on dense networks where zooming alone would lose the global context the fisheye becomes genuinely useful.
g6(nodes, edges) |>
g6_layout() |>
g6_plugins(fish_eye())13.8.6 Minimap
A minimap provides a thumbnail overview of the entire graph, with a shaded rectangle indicating the currently visible viewport. It is overkill for the 15-node graph in Figure 13.17, but becomes indispensable for networks in the thousands of nodes.
g6(nodes, edges) |>
g6_layout() |>
g6_plugins(minimap())