cynkra


g6R update: collapsible nodes and combos.

From the Blog
Shiny
R

Author

David Granjon

Published

Introduction

In the previous post, we introduced ports in g6R, which brought precise connection points to nodes for building interactive data workflows. In this post, we look at another new feature: collapsible nodes and combos.

As graphs grow in size, they can quickly become hard to read. Collapsible nodes let you hide parts of the graph behind a single node, reducing visual clutter while preserving the full structure underneath. Clicking a collapse button on a parent node hides all of its children and clicking again restores them. The same mechanism works on combos, which are visual containers that group nodes together.

To get started, install the latest development version:

pak::pak("cynkra/g6R")

New features

The improvements presented here do not replace the existing collapse_expand() behavior, which handles combos and nodes. collapse_expand() works well for tree structures but falls short with DAGs, where a child can have multiple parents. Our approach is designed to handle these more complex topologies correctly.

Collapsible nodes

g6_node() now accepts a collapse parameter configured via g6_collapse_options(). This works with any of the custom-*-node types for instance custom-rect-node or custom-circle-node. When a node has children defined, a character vector of child node IDs, it displays a collapse button:

g6_node(
  id = "a",
  type = "custom-rect-node",
  style = list(labelText = "dataset: iris"),
  children = c("b", "c"),
  collapse = g6_collapse_options(collapsed = TRUE)
)

The g6_collapse_options() function provides full control over the button’s appearance and behavior:

g6_collapse_options(
  collapsed = FALSE,       # initial state
  visibility = "visible",  # "visible" or "hover"
  placement = "right-top", # preset name or c(x, y) coordinates
  r = 6,                   # button radius
  fill = "#fff",           # background color
  stroke = "#9cabd4",      # border color
  iconStroke = "#9cabd4"   # plus/minus icon color
)

When a node is collapsed, its children are hidden and a badge shows the count of hidden nodes. Edges connected to hidden children are rerouted to the collapsed parent so the graph remains readable.

Here is a minimal example with three nodes where a can collapse its two children:

library(shiny)
library(g6R)

ui <- fluidPage(
  g6_output("graph", height = "400px")
)

server <- function(input, output, session) {
  output$graph <- render_g6(
    g6(
      nodes = g6_nodes(
        g6_node(
          id = "a",
          type = "custom-rect-node",
          style = list(labelText = "Parent"),
          children = c("b", "c"),
          collapse = g6_collapse_options()
        ),
        g6_node(
          id = "b",
          type = "custom-rect-node",
          style = list(labelText = "Child 1")
        ),
        g6_node(
          id = "c",
          type = "custom-rect-node",
          style = list(labelText = "Child 2")
        )
      ),
      edges = g6_edges(
        g6_edge(source = "a", target = "b"),
        g6_edge(source = "a", target = "c")
      )
    ) |>
      g6_layout(antv_dagre_layout()) |>
      g6_behaviors(drag_element(), zoom_canvas())
  )
}

shinyApp(ui, server)

Expanded graph showing Parent node with two children.

Collapsed Parent node with a badge showing 2 hidden children.

In a DAG, a child node can have multiple parents. When two parents share common children, collapsing one parent also collapses the other to keep the graph state consistent. In the screenshot below, both the Head node (combo 1) and the Subset node (combo 2) share downstream children. Collapsing Head automatically collapses Subset as well, both showing a “+4” badge for their hidden descendants.

Two parent nodes with shared children both collapsed to keep consistency.

Automatic parent/child tracking

When a node has children set, g6R automatically sets the g6R.directed_graph option to TRUE. This means that when you create a connection between two nodes (via create_edge() or proxy functions), the parent/child relationship is automatically established. Likewise, when an edge or node is removed through g6_remove_edges() or g6_remove_nodes(), the relationship is cleaned up. Importantly, the parent/child tracking only works when using g6R proxy functions. Calling the G6 JS API directly (e.g. graph.removeEdgeData(...)) will not keep the tree state in sync.

Controlling collapse depth

set_g6_max_collapse_depth() controls which nodes display a collapse button based on their depth in the graph. Depth is computed automatically from the parent/child relationships. Root nodes, those with no parent, have depth 0. Their direct children have depth 1, and so on. Only nodes whose depth is less than or equal to maxCollapseDepth will show a collapse button.

The three key values are:

  • Inf (default): all nodes with children are collapsible, regardless of how deep they are in the hierarchy.
  • 0: only root nodes show collapse buttons. This is useful when you want a single top-level toggle that collapses everything below, without allowing intermediate nodes to be collapsed independently.
  • -1: collapsing is entirely disabled. Collapse buttons are removed from all nodes, even if g6_collapse_options() is provided and nodes have children. This can be useful if you think the graph complexity is manageable without collapsing.

For example, consider a graph with three levels: a -> b -> c. With set_g6_max_collapse_depth(0), only node a (depth 0) would show a collapse button. Node b (depth 1) would not, even though it has a child.

The two apps below illustrate the difference. Both use the same three-level graph a -> b -> c, but with different depth limits.

With set_g6_max_collapse_depth(Inf), both a and b show a collapse button:

library(shiny)
library(g6R)

set_g6_max_collapse_depth(Inf)

ui <- fluidPage(
  g6_output("graph", height = "400px")
)

server <- function(input, output, session) {
  output$graph <- render_g6(
    g6(
      nodes = g6_nodes(
        g6_node(
          id = "a",
          type = "custom-rect-node",
          style = list(labelText = "a (depth 0)"),
          children = "b",
          collapse = g6_collapse_options()
        ),
        g6_node(
          id = "b",
          type = "custom-rect-node",
          style = list(labelText = "b (depth 1)"),
          children = "c",
          collapse = g6_collapse_options()
        ),
        g6_node(
          id = "c",
          type = "custom-rect-node",
          style = list(labelText = "c (depth 2)")
        )
      ),
      edges = g6_edges(
        g6_edge(source = "a", target = "b"),
        g6_edge(source = "b", target = "c")
      )
    ) |>
      g6_layout(antv_dagre_layout()) |>
      g6_behaviors(drag_element(), zoom_canvas())
  )
}

shinyApp(ui, server)

Three-level graph with collapse buttons on both a and b nodes.

With set_g6_max_collapse_depth(0), only node a (depth 0) shows a collapse button. Node b does not, even though it has a child:

library(shiny)
library(g6R)

set_g6_max_collapse_depth(0)

ui <- fluidPage(
  g6_output("graph", height = "400px")
)

server <- function(input, output, session) {
  output$graph <- render_g6(
    g6(
      nodes = g6_nodes(
        g6_node(
          id = "a",
          type = "custom-rect-node",
          style = list(labelText = "a (depth 0)"),
          children = "b",
          collapse = g6_collapse_options()
        ),
        g6_node(
          id = "b",
          type = "custom-rect-node",
          style = list(labelText = "b (depth 1)"),
          children = "c",
          collapse = g6_collapse_options()
        ),
        g6_node(
          id = "c",
          type = "custom-rect-node",
          style = list(labelText = "c (depth 2)")
        )
      ),
      edges = g6_edges(
        g6_edge(source = "a", target = "b"),
        g6_edge(source = "b", target = "c")
      )
    ) |>
      g6_layout(antv_dagre_layout()) |>
      g6_behaviors(drag_element(), zoom_canvas())
  )
}

shinyApp(ui, server)

You can also call options(g6R.max_collapse_depth) directly, even if the latter does not validate the passed value.

Collapsible combos

This feature was already available in previous versions but has been substantially improved in this release. g6_combo() also supports the collapse parameter with g6_collapse_options(). When collapse is provided and type is NULL, the combo type is automatically set to "rect-combo-with-extra-button":

g6_combo(
  "combo1",
  collapse = g6_collapse_options(
    placement = "bottom",
    fill = "#f0f0f0",
    visibility = "hover"
  )
)

The combo collapse button supports all the same styling options as nodes such as configurable placement, radius, fill, stroke, icon stroke, and visibility modes.

The following example shows a combo containing two nodes where a is the parent of b. Both the combo and node a are collapsible. When you collapse node a, its child b is hidden and the combo automatically resizes to fit the remaining visible node:

library(shiny)
library(g6R)

ui <- fluidPage(
  g6_output("graph", height = "400px")
)

server <- function(input, output, session) {
  output$graph <- render_g6(
    g6(
      nodes = g6_nodes(
        g6_node(
          id = "a",
          type = "custom-rect-node",
          style = list(labelText = "Parent"),
          combo = "combo1",
          children = "b",
          collapse = g6_collapse_options()
        ),
        g6_node(
          id = "b",
          type = "custom-rect-node",
          style = list(labelText = "Child"),
          combo = "combo1"
        )
      ),
      edges = g6_edges(
        g6_edge(source = "a", target = "b")
      ),
      combos = g6_combos(
        g6_combo(
          "combo1",
          collapse = g6_collapse_options(placement = "right")
        )
      )
    ) |>
      g6_layout(antv_dagre_layout()) |>
      g6_options(
        renderer = JS("() => new SVGRenderer()")
      ) |>
      g6_behaviors(
        drag_element(),
        zoom_canvas()
      )
  )
}

shinyApp(ui, server)

Combo with Parent and Child nodes both expanded.

Parent node collapsed inside the combo, hiding the Child. The combo resizes to fit.

Combo itself collapsed, hiding all its content.

Combo virtual edges

The next example shows a more realistic setup with two combos and cross-combo connections. When collapsing a combo, we show virtual edges from the combo to any nodes outside that are connected to hidden children. This way you can still see how the collapsed content relates to the rest of the graph:

library(blockr.dag)
library(blockr.core)
library(blockr.dock)

options(
  "g6R.mode" = "dev" #,
  #"g6R.layout_on_data_change" = TRUE
)

serve(
  new_dock_board(
    blocks = c(
      a = new_dataset_block("iris"),
      b = new_head_block(n = 10),
      c = new_subset_block(),
      d = new_head_block(n = 5),
      e = new_rbind_block(),
      f = new_subset_block(),
      g = new_rbind_block(),
      h = new_head_block(n = 3),
      i = new_scatter_block(x = "Sepal.Length", y = "Sepal.Width")
    ),
    links = c(
      new_link(from = "a", to = "b", input = "data"),
      new_link(from = "b", to = "c", input = "data"),
      new_link(from = "c", to = "d", input = "data"),
      new_link(from = "b", to = "f", input = "data"),
      new_link(from = "d", to = "e", input = "1"),
      new_link(from = "f", to = "e", input = "2"),
      new_link(from = "e", to = "g", input = "1"),
      new_link(from = "f", to = "g", input = "2"),
      new_link(from = "g", to = "h", input = "data"),
      new_link(from = "h", to = "i", input = "data")
    ),
    stacks = c(
      new_stack(id = "stack1", blocks = c("b", "c", "d")),
      new_stack(id = "stack2", blocks = c("f", "g"))
    ),
    extensions = new_dag_extension()
  )
)

Expanded blockr.dag workflow with two stacks and cross-combo connections.

Same workflow with the first stack collapsed, showing virtual dashed edges to connected nodes outside.

Overlay plugin improvements

bubble_sets() and hull() now automatically set pointerEvents = "none" and zIndex = -1 by default. This fixes two issues when using the SVG renderer:

  • Overlay shapes no longer block pointer events like drag and click on nodes.
  • They render behind nodes so they don’t visually cover collapse buttons or other node UI.

These defaults can be overridden by passing explicit values.

Additionally, overlay plugins now resize dynamically when nodes are collapsed or uncollapsed. Hidden members are temporarily removed from the overlay shape and restored when expanded again.

The following example uses bubble_sets() to group three nodes. Collapsing node a hides b and c, and the bubble set shrinks to wrap only the remaining visible node:

library(shiny)
library(g6R)

ui <- fluidPage(
  g6_output("graph", height = "400px")
)

server <- function(input, output, session) {
  output$graph <- render_g6(
    g6(
      nodes = g6_nodes(
        g6_node(
          id = "a",
          type = "custom-rect-node",
          style = list(labelText = "a"),
          children = c("b", "c"),
          collapse = g6_collapse_options()
        ),
        g6_node(
          id = "b",
          type = "custom-rect-node",
          style = list(labelText = "b")
        ),
        g6_node(
          id = "c",
          type = "custom-rect-node",
          style = list(labelText = "c")
        )
      ),
      edges = g6_edges(
        g6_edge(source = "a", target = "b"),
        g6_edge(source = "a", target = "c")
      )
    ) |>
      g6_layout(antv_dagre_layout()) |>
      g6_options(
        renderer = JS("() => new SVGRenderer()")
      ) |>
      g6_behaviors(drag_element(), zoom_canvas()) |>
      g6_plugins(
        bubble_sets(
          members = c("a", "b", "c"),
          label = TRUE,
          labelText = "bubble set",
          fill = "#F08F56",
          stroke = "#F08F56",
          labelBackground = TRUE,
          labelPlacement = "top",
          labelFill = "#fff",
          labelPadding = 2,
          labelBackgroundFill = "#F08F56",
          labelBackgroundRadius = 5
        )
      )
  )
}

shinyApp(ui, server)

Bubble set resizing dynamically when nodes are collapsed and uncollapsed.

The same applies to hull(). In this example, collapsing node a hides its children and the hull resizes accordingly:

library(shiny)
library(g6R)

ui <- fluidPage(
  g6_output("graph", height = "400px")
)

server <- function(input, output, session) {
  output$graph <- render_g6(
    g6(
      nodes = g6_nodes(
        g6_node(
          id = "a",
          type = "custom-rect-node",
          style = list(labelText = "a"),
          children = c("b", "c"),
          collapse = g6_collapse_options()
        ),
        g6_node(
          id = "b",
          type = "custom-rect-node",
          style = list(labelText = "b")
        ),
        g6_node(
          id = "c",
          type = "custom-rect-node",
          style = list(labelText = "c")
        )
      ),
      edges = g6_edges(
        g6_edge(source = "a", target = "b"),
        g6_edge(source = "a", target = "c")
      )
    ) |>
      g6_layout(antv_dagre_layout()) |>
      g6_options(
        renderer = JS("() => new SVGRenderer()")
      ) |>
      g6_behaviors(drag_element(), zoom_canvas()) |>
      g6_plugins(
        hull(
          members = c("a", "b", "c"),
          labelText = "hull",
          fill = "#7B68EE",
          stroke = "#7B68EE",
          labelBackground = TRUE,
          labelPlacement = "top",
          labelFill = "#fff",
          labelPadding = 2,
          labelBackgroundFill = "#7B68EE",
          labelBackgroundRadius = 5
        )
      )
  )
}

shinyApp(ui, server)

Hull resizing dynamically when nodes are collapsed and uncollapsed.

Example

The Shiny following application summarises all the listed features. The context menu allows you to remove edges and nodes, and see how parent/child relationships are automatically maintained.

library(shiny)
library(g6R)

options(
  "g6R.mode" = "dev",
  "g6R.max_collapse_depth" = Inf,
  "g6R.directed_graph" = TRUE
)

ui <- fluidPage(
  g6_output("dag", height = "1000px"),
  verbatimTextOutput("clicked_port"),
  verbatimTextOutput("removed_node")
)

server <- function(input, output, session) {
  output$dag <- render_g6(
    g6(
      nodes = g6_nodes(
        g6_node(
          id = "a",
          type = "custom-rect-node",
          style = list(labelText = "dataset: iris"),
          ports = g6_ports(
            g6_output_port(
              key = "output-a",
              placement = "bottom",
              label = "data"
            )
          ),
          children = c("b"),
          collapse = g6_collapse_options(
            collapsed = TRUE,
            visibility = "hover",
            placement = "right-top"
          )
        ),
        g6_node(
          id = "b",
          type = "custom-rect-node",
          style = list(labelText = "head(n=10)"),
          ports = g6_ports(
            g6_input_port(
              key = "input-b",
              placement = "top",
              label = "data"
            ),
            g6_output_port(
              key = "output-b",
              placement = "bottom",
              label = "data"
            )
          ),
          combo = "combo1",
          children = c("c", "f"),
          collapse = g6_collapse_options(placement = "right-top")
        ),
        g6_node(
          id = "c",
          type = "custom-rect-node",
          style = list(labelText = "subset"),
          ports = g6_ports(
            g6_input_port(
              key = "input-c",
              placement = "top",
              label = "data"
            ),
            g6_output_port(
              key = "output-c",
              placement = "bottom",
              label = "data"
            )
          ),
          combo = "combo1",
          children = "d"
        ),
        g6_node(
          id = "d",
          type = "custom-rect-node",
          style = list(labelText = "head(n=5)"),
          ports = g6_ports(
            g6_input_port(
              key = "input-d",
              placement = "top",
              label = "data"
            ),
            g6_output_port(
              key = "output-d",
              placement = "bottom",
              label = "data"
            )
          ),
          combo = "combo1",
          children = c("e"),
          collapse = g6_collapse_options(
            collapsed = FALSE,
            placement = "right-top"
          )
        ),
        g6_node(
          id = "e",
          type = "custom-rect-node",
          style = list(labelText = "rbind"),
          ports = g6_ports(
            g6_input_port(
              key = "input-e-1",
              placement = "top",
              label = "1",
              arity = Inf
            ),
            g6_input_port(
              key = "input-e-2",
              placement = "top",
              label = "2",
              arity = Inf
            ),
            g6_output_port(
              key = "output-e",
              placement = "bottom",
              label = "data",
              arity = Inf
            )
          ),
          children = c("g"),
          collapse = g6_collapse_options(
            collapsed = FALSE,
            placement = "right-top"
          )
        ),
        g6_node(
          id = "f",
          type = "custom-rect-node",
          style = list(labelText = "subset"),
          ports = g6_ports(
            g6_input_port(
              key = "input-f",
              placement = "top",
              label = "data"
            ),
            g6_output_port(
              key = "output-f",
              placement = "bottom",
              label = "data"
            )
          ),
          combo = "combo2",
          children = c("e", "g"),
          collapse = g6_collapse_options(
            collapsed = FALSE,
            placement = "right-top"
          )
        ),
        g6_node(
          id = "g",
          type = "custom-rect-node",
          style = list(labelText = "rbind"),
          ports = g6_ports(
            g6_input_port(
              key = "input-g-1",
              placement = "top",
              label = "1",
              arity = Inf
            ),
            g6_input_port(
              key = "input-g-2",
              placement = "top",
              label = "2",
              arity = Inf
            ),
            g6_output_port(
              key = "output-g",
              placement = "bottom",
              label = "data",
              arity = Inf
            )
          ),
          combo = "combo2",
          children = c("h"),
          collapse = g6_collapse_options(
            collapsed = FALSE,
            placement = "right-top"
          )
        ),
        g6_node(
          id = "h",
          type = "custom-rect-node",
          style = list(labelText = "head(n=3)"),
          ports = g6_ports(
            g6_input_port(
              key = "input-h",
              placement = "top",
              label = "data"
            ),
            g6_output_port(
              key = "output-h",
              placement = "bottom",
              label = "data"
            )
          ),
          children = c("i"),
          collapse = g6_collapse_options(
            collapsed = FALSE,
            placement = "right-top"
          )
        ),
        g6_node(
          id = "i",
          type = "custom-rect-node",
          style = list(labelText = "scatter plot"),
          ports = g6_ports(
            g6_input_port(
              key = "input-i",
              placement = "top",
              label = "data"
            ),
            g6_output_port(
              key = "output-i",
              placement = "bottom",
              label = "data"
            )
          ),
          children = c("j"),
          collapse = g6_collapse_options(placement = "right-top")
        ),
        g6_node(
          id = "j",
          type = "custom-rect-node",
          style = list(labelText = "filter"),
          ports = g6_ports(
            g6_input_port(
              key = "input-j",
              placement = "top",
              label = "data"
            ),
            g6_output_port(
              key = "output-j",
              placement = "bottom",
              label = "data"
            )
          ),
          children = c("k", "l"),
          collapse = g6_collapse_options(placement = "right-top")
        ),
        g6_node(
          id = "k",
          type = "custom-rect-node",
          style = list(labelText = "summarise"),
          ports = g6_ports(
            g6_input_port(
              key = "input-k",
              placement = "top",
              label = "data"
            ),
            g6_output_port(
              key = "output-k",
              placement = "bottom",
              label = "data"
            )
          ),
          children = c("m"),
          collapse = g6_collapse_options(placement = "right-top")
        ),
        g6_node(
          id = "l",
          type = "custom-rect-node",
          style = list(labelText = "mutate"),
          ports = g6_ports(
            g6_input_port(
              key = "input-l",
              placement = "top",
              label = "data"
            ),
            g6_output_port(
              key = "output-l",
              placement = "bottom",
              label = "data"
            )
          ),
          children = c("n"),
          collapse = g6_collapse_options(placement = "right-top")
        ),
        g6_node(
          id = "m",
          type = "custom-rect-node",
          style = list(labelText = "arrange"),
          ports = g6_ports(
            g6_input_port(
              key = "input-m",
              placement = "top",
              label = "data"
            ),
            g6_output_port(
              key = "output-m",
              placement = "bottom",
              label = "data"
            )
          )
        ),
        g6_node(
          id = "n",
          type = "custom-rect-node",
          style = list(labelText = "select"),
          ports = g6_ports(
            g6_input_port(
              key = "input-n",
              placement = "top",
              label = "data"
            )
          )
        )
      ),
      edges = g6_edges(
        g6_edge(
          source = "a", target = "b",
          style = list(
            sourcePort = "output-a", targetPort = "input-b",
            endArrow = TRUE, startArrow = FALSE, endArrowType = "vee"
          )
        ),
        g6_edge(
          source = "b", target = "c",
          style = list(
            sourcePort = "output-b", targetPort = "input-c",
            endArrow = TRUE, startArrow = FALSE, endArrowType = "vee"
          )
        ),
        g6_edge(
          source = "c", target = "d",
          style = list(
            sourcePort = "output-c", targetPort = "input-d",
            endArrow = TRUE, startArrow = FALSE, endArrowType = "vee"
          )
        ),
        g6_edge(
          source = "b", target = "f",
          style = list(
            sourcePort = "output-b", targetPort = "input-f",
            endArrow = TRUE, startArrow = FALSE, endArrowType = "vee"
          )
        ),
        g6_edge(
          source = "d", target = "e",
          style = list(
            sourcePort = "output-d", targetPort = "input-e-1",
            endArrow = TRUE, startArrow = FALSE, endArrowType = "vee"
          )
        ),
        g6_edge(
          source = "f", target = "e",
          style = list(
            sourcePort = "output-f", targetPort = "input-e-2",
            endArrow = TRUE, startArrow = FALSE, endArrowType = "vee"
          )
        ),
        g6_edge(
          source = "e", target = "g",
          style = list(
            sourcePort = "output-e", targetPort = "input-g-1",
            endArrow = TRUE, startArrow = FALSE, endArrowType = "vee"
          )
        ),
        g6_edge(
          source = "f", target = "g",
          style = list(
            sourcePort = "output-f", targetPort = "input-g-2",
            endArrow = TRUE, startArrow = FALSE, endArrowType = "vee"
          )
        ),
        g6_edge(
          source = "g", target = "h",
          style = list(
            sourcePort = "output-g", targetPort = "input-h",
            endArrow = TRUE, startArrow = FALSE, endArrowType = "vee"
          )
        ),
        g6_edge(
          source = "h", target = "i",
          style = list(
            sourcePort = "output-h", targetPort = "input-i",
            endArrow = TRUE, startArrow = FALSE, endArrowType = "vee"
          )
        ),
        g6_edge(
          source = "i", target = "j",
          style = list(
            sourcePort = "output-i", targetPort = "input-j",
            endArrow = TRUE, startArrow = FALSE, endArrowType = "vee"
          )
        ),
        g6_edge(
          source = "j", target = "k",
          style = list(
            sourcePort = "output-j", targetPort = "input-k",
            endArrow = TRUE, startArrow = FALSE, endArrowType = "vee"
          )
        ),
        g6_edge(
          source = "j", target = "l",
          style = list(
            sourcePort = "output-j", targetPort = "input-l",
            endArrow = TRUE, startArrow = FALSE, endArrowType = "vee"
          )
        ),
        g6_edge(
          source = "k", target = "m",
          style = list(
            sourcePort = "output-k", targetPort = "input-m",
            endArrow = TRUE, startArrow = FALSE, endArrowType = "vee"
          )
        ),
        g6_edge(
          source = "l", target = "n",
          style = list(
            sourcePort = "output-l", targetPort = "input-n",
            endArrow = TRUE, startArrow = FALSE, endArrowType = "vee"
          )
        )
      ),
      combos = g6_combos(
        g6_combo(
          "combo1",
          collapse = g6_collapse_options(visibility = "hover")
        ),
        g6_combo(
          "combo2",
          collapse = g6_collapse_options()
        )
      )
    ) |>
      g6_layout(antv_dagre_layout(sortByCombo = TRUE)) |>
      g6_options(
        animation = FALSE,
        autoFit = TRUE,
        renderer = JS("() => new SVGRenderer()"),
        edge = list(style = list(endArrow = TRUE))
      ) |>
      g6_behaviors(
        collapse_expand(
          enable = JS("(e) => e.targetType === 'combo'")
        ),
        click_select(multiple = TRUE),
        drag_element(),
        drag_canvas(
          enable = JS(
            "(e) => {
              return e.targetType === 'canvas' && !e.shiftKey && !e.altKey;
            }"
          )
        ),
        zoom_canvas(),
        create_edge(
          enable = JS(
            "(e) => {
              return e.targetType === 'node' && e.targetType !== 'combo';
            }"
          ),
          onFinish = NULL
        )
      ) |>
      g6_plugins(
        bubble_sets(
          key = "bubble-set-1",
          members = c("j", "k"),
          label = TRUE,
          labelText = "bubble set",
          fill = "#F08F56",
          stroke = "#F08F56",
          labelBackground = TRUE,
          labelPlacement = "top",
          labelFill = "#fff",
          labelPadding = 2,
          labelBackgroundFill = "#F08F56",
          labelBackgroundRadius = 5
        ),
        hull(
          key = "hull-1",
          members = c("l", "m", "n"),
          labelText = "Super hull",
          labelAutoRotate = FALSE,
          labelCloseToPath = FALSE,
          fill = "#7B68EE",
          stroke = "#7B68EE",
          labelBackground = TRUE,
          labelPlacement = "top",
          labelFill = "#fff",
          labelPadding = 2,
          labelBackgroundFill = "#7B68EE",
          labelBackgroundRadius = 5
        ),
        context_menu(
          enable = JS("(e) => true"),
          getItems = JS(
            "(e) => {
              if (e.targetType === 'edge') {
                return [{ name: 'Remove edge', value: 'remove_edge' }];
              } else if (e.targetType === 'node') {
                return [{ name: 'Remove node', value: 'remove_node' }];
              }
              return [];
            }"
          ),
          onClick = JS(
            "(value, target, current) => {
              const graph = HTMLWidgets
                .find(`#${target.closest('.g6').id}`)
                .getWidget();
              if (current.id === undefined) return;
              if (value === 'remove_edge') {
                Shiny.setInputValue(
                  target.closest('.g6').id + '-removed_edge',
                  {id: current.id},
                  {priority: 'event'}
                );
              } else if (value === 'remove_node') {
                Shiny.setInputValue(
                  target.closest('.g6').id + '-removed_node',
                  {id: current.id},
                  {priority: 'event'}
                );
              }
            }"
          )
        )
      )
  )

  output$clicked_port <- renderPrint({
    input[["dag-selected_port"]]
  })

  output$removed_node <- renderPrint({
    input[["dag-removed_node"]]
  })

  proxy <- g6_proxy("dag")

  observeEvent(input[["dag-selected_port"]], {
    new_id <- as.character(round(as.numeric(Sys.time())))
    pos <- input[["dag-mouse_position"]]

    proxy |>
      g6_add_nodes(
        g6_node(
          id = new_id,
          type = "custom-circle-node",
          style = list(
            x = pos$x + 50,
            y = pos$y,
            labelText = paste("Node", new_id)
          ),
          ports = g6_ports(
            g6_input_port(
              key = sprintf("input-%s", new_id),
              placement = "left",
              arity = Inf
            ),
            g6_output_port(
              key = sprintf("output-%s", new_id),
              placement = "right",
              arity = Inf
            )
          )
        )
      ) |>
      g6_add_edges(
        g6_edge(
          source = as.character(
            input[["dag-selected_port"]][["node"]]
          ),
          target = new_id,
          style = list(
            sourcePort = input[["dag-selected_port"]][["port"]],
            targetPort = sprintf("input-%s", new_id),
            endArrow = TRUE
          )
        )
      )
  })

  observeEvent(input[["dag-removed_edge"]], {
    proxy |>
      g6_remove_edges(input[["dag-removed_edge"]]$id)
  })

  observeEvent(input[["dag-removed_node"]], {
    proxy |>
      g6_remove_nodes(input[["dag-removed_node"]]$id)
  })
}

shinyApp(ui, server)

Conclusion

This feature was developed by supervising an AI coding agent (Claude) through a structured workflow. Before writing any code, we prepared a set of files capturing motivations, requirements, design decisions and implementation details. This upfront planning helped the AI understand the existing codebase and produce code that respects its conventions, rather than introducing a foreign style. While the AI handled the bulk of the implementation across many files, human review and adjustements remained essential to catch edge cases and ensure the result integrates cleanly with the rest of g6R.

Next steps

If you have feedback, reach out via the contact page on this site. You can also report issues or feature requests on the g6R repository.