cynkra


blockr.dag update: ports.

From the Blog
Shiny
R

Author

David Granjon

Published

Introduction

blockr is a new tool to build data analyses and apps in minutes, using a point and click user interface. Central to this new tool is a block-based pipeline builder which allows you to specify your analyses as a series of connected nodes in a graph. In this blog post we are going to dive into blockr.dag, an extension that powers this blockr graph, and g6R, the R package which powers this extension.

Workflow builder in a blockr app.

To get started quickly, install the latest development versions with:

# Latest GitHub main branches
pak::pak("cynkra/g6R")
pak::pak("BristolMyersSquibb/blockr.dag")

What are ports?

g6R 0.6.0 (preview) introduces ports, which are connection points displayed on the borders of nodes. You can think of ports like the ports on the back of a computer motherboard, where you connect cables or peripherals such as USB ports.

Workflow builder in a blockr app.

Ports are labeled so you know where to connect elements, just as your computer needs the right connections to function. In data science, ports are ideal for organizing complex data flows.

Using ports in blockr.dag

Block categories and port arities

In blockr, there are three main categories of blocks:

  • Input blocks, where data enters the workflow (e.g., dataset block).
  • Transformation blocks, which modify data (e.g., filter rows block).
  • Plot blocks, which visualize data (e.g., ggplot2 block).

All blocks have a single output port, which outputs raw or transformed data. This port has infinite arity, meaning it can connect to multiple input ports on other blocks.

Blocks may have input ports with different arities. For example, the transformation Join block has two input ports—one for the left table and one for the right table. Each of these ports has an arity of one, so they can only connect to a single output port from another block.

Example of blocks with input and output ports.

Other blocks, such as bind rows or columns, have input ports with infinite arity, allowing them to connect to multiple output ports and bind any number of tables together. Entry-point blocks, like the dataset block, have no input ports since they are the starting point for data.

Connecting ports

There are three ways to connect ports in a blockr application:

  • Click on the port guide and follow the instructions in the app. Hovering over a free port displays an arrow cursor indicating the drag direction.
  • Drag and drop from one port to another. In blockr.dag, you must hold ‘Shift’ while dragging to connect ports.
  • Right click on a block to open the context menu and select ‘Create link’. This connects the current node (with only one output port), to a selected port of another block.

Using port guides to connect blocks.

Once connected, data can flow from one block to another. If you hover over a port that has reached its maximum number of connections, the port guide changes to indicate the port is blocked. You cannot connect to this port unless you remove existing connections.

Workflow builder in a blockr app.

How to create ports with g6R

Now we have seen how ports work in blockr, let’s jump into some of the technical details and see how to define ports with g6R.

Define ports

To enable ports, pass a custom type to g6_node(), such as custom-circle-node or custom-rect-node. Nine shapes are compatible with ports, except HTML. The g6_node() function now has a ports argument to define ports for each node. In the g6 JS library, ports are usually defined inside style, but we consider them important enough to define directly in the node data. g6R automatically moves them to style.ports when rendering the graph. That said, if you already were using ports within style, they should still work.

Use the g6_port() function to create ports and wrap them in g6_ports():

g6_port(
  key = "key",
  label = "Port label",
  type = "input",
  arity = 1,
  placement = "left",
  fill = "#52C41A",
  r = 4
)

A port has a unique key, a type (either input or output), an arity (number of connections allowed), and other style parameters inherited from g6. g6R automatically makes port keys unique by prefixing them with the node ID on the JS side. We added a label parameter (not in the G6 API) to display a tooltip when hovering over the port. Port placement is controlled by the placement argument, which supports 6 values: top, bottom, left, right, top-left, top-right, bottom-left, bottom-right, or a set of (x, y) coordinates (0, 0 is the top-left corner). Port visiblity is controlled with the visibility parameter which can be either visible (default), hover or hidden. Ports can only be displayed on the node border; invalid coordinates will cause an error. Combining input[["<GRAPH_ID>-selected_port"]] and input[["<graph_ID>-mouse_position"]], you can add and connect nodes on the fly at the guide location (see example below).

For use in other packages like blockr.dag, two helper functions exist:

  • Input ports (g6_input_port()): can only be the target of an edge.
  • Output ports (g6_output_port()): can only be the source of an edge.

Connect ports with edges

When creating edges, provide sourcePort and/or targetPort within the style list to connect to the corresponding ports. Validation ensures you cannot connect incompatible ports (e.g., output to output, or a port to itself):

g6_edge(
  source = 1,
  target = 2,
  style = list(
    sourcePort = "output-1",
    targetPort = "input-2",
    endArrow = TRUE,
    startArrow = FALSE,
    endArrowType = "vee"
  )
)

The create_edge() behavior has also been improved to work better with ports. For example, you cannot drag from a port that has reached its arity limit, or from a node with ports (drag from the ports instead).

Update ports

Ports can be updated from the server with g6_update_ports(), which supports three actions, namely remove, add, or update:

g6_update_ports(
  g6_proxy("graph"),
  ids = c("A", "B"),
  ops = list(
    A = list(remove = c("out1", "out2")),
    B = list(
      add = list(g6_port(key = "new", label = "new", placement = "top")),
      update = list(g6_port(key = "in2", label = "Updated label"))
    )
  )
)

idsrefers to existing node IDs and ops is a list of possible operations per node. When the action is remove, we pass a vector of ports to be removed. For add and update, we pass a list of g6_port() objects. The Shiny application below shows how to leverage g6_update_ports():

library(shiny)
library(g6R)

ui <- fluidPage(
  g6_output("graph", height = "500px"),
  actionButton("update_ports", "Update Ports")
)

  server <- function(input, output, session) {
    output$graph <- render_g6({
      g6(
        nodes = g6_nodes(
          g6_node(
            id = "A",
            ports = g6_ports(
              g6_input_port(key = "in1", label = "in1", placement = "left"),
              g6_output_port(key = "out1", label = "out1", placement = "right"),
              g6_output_port(key = "out2", label = "out2", placement = c(1, 0.7))
            ),
            style = list(x = 100, y = 200, labelText = "Node A")
          ),
          g6_node(
            id = "B",
            ports = g6_ports(
              g6_input_port(key = "in2", label = "in2", placement = "left"),
              g6_output_port(key = "out3", label = "out3", placement = "right"),
              g6_output_port(key = "out4", label = "out4", placement = c(1, 0.3))
            ),
            style = list(x = 300, y = 200, labelText = "Node B")
          )
        ),
        edges = g6_edges(
          g6_edge(source = "A", target = "B", style = list(sourcePort = "out1", targetPort = "in2"))
        )
      ) |> g6_behaviors(click_select(), drag_element(), drag_canvas())
    })

    observeEvent(input$update_ports, {
      g6_update_ports(
        g6_proxy("graph"),
        c("A", "B"),
        list(
          A = list(remove = c("out1", "out2")),
          B = list(
            add = list(g6_port(key = "new", label = "new", placement = "top")),
            update = list(g6_port(key = "in2", label = "Updated label"))
          )
        )
      )
    })
  }

shinyApp(ui, server)

You can also get ports with g6_get_ports(), or specifically g6_get_input_ports() and g6_get_output_ports().

Use case: interactive network builder.

The example below, an interactive network builder where you can append new nodes by clicking a port guide, summarizes these features. The new node is created at the mouse position and connected to the clicked port.

Interactive g6R builder app.

library(shiny)
library(g6R)

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

ui <- fluidPage(
  g6_output("dag"),
  verbatimTextOutput("clicked_port")
)

server <- function(input, output, session) {
  output$dag <- render_g6(
    g6(
      nodes = g6_nodes(
        g6_node(
          id = 1,
          type = "custom-circle-node",
          style = list(
            labelText = "Node 1"
          ),
          ports = g6_ports(
            g6_input_port(
              key = "input-1",
              placement = "left"
            ),
            g6_output_port(
              key = "output-1",
              placement = "right"
            ),
            g6_input_port(
              key = "input-12",
              placement = "top"
            )
          )
        ),
        g6_node(
          id = 2,
          type = "custom-circle-node",
          style = list(
            labelText = "Node 2"
          ),
          ports = g6_ports(
            g6_input_port(
              key = "input-2",
              placement = "left"
            ),
            g6_output_port(
              key = "output-2",
              placement = "right"
            )
          )
        )
      ),
      edges = g6_edges(
        g6_edge(
          source = 1,
          target = 2,
          style = list(
            sourcePort = "output-1",
            targetPort = "input-2",
            endArrow = TRUE,
            startArrow = FALSE,
            endArrowType = "vee"
          )
        )
      )
    ) |>
      g6_layout() |>
      g6_options(
        animation = FALSE,
        edge = list(style = list(endArrow = TRUE))
      ) |>
      g6_behaviors(
        click_select(multiple = TRUE),
        drag_element(
          enable = JS(
            "(e) => {
          return !e.shiftKey && !e.altKey;
        }"
          )
        ),
        drag_canvas(
          enable = JS(
            "(e) => {
          return e.targetType === 'canvas' && !e.shiftKey && !e.altKey;
        }"
          )
        ),
        zoom_canvas(),
        create_edge(
          enable = JS(
            "(e) => {
        return e.shiftKey}"
          )
        )
      ) |>
      # Allow to dynamically remove an edge
      g6_plugins(
        context_menu(
          enable = JS("(e) => e.targetType === 'edge'"),
          getItems = JS(
            "() => {
              return [
                { name: 'Remove edge', value: 'remove_edge' }
              ];
            }"
          ),
          onClick = JS(
            "(value, target, current) => {
              const graph = HTMLWidgets
                .find(`#${target.closest('.g6').id}`)
                .getWidget();
              console.log(current.id);
              if (current.id === undefined) return;
              if (value === 'remove_edge') {
                graph.removeEdgeData([current.id]);
                graph.draw();
              }
            }
          "
          )
        )
      )
  )

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

  proxy <- g6_proxy("dag")

  # Add a new node when a port is clicked from the guide
  # at the mouse position (close to the guide)
  observeEvent(input[["dag-selected_port"]], {
    new_id <- 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, # avoids overlapping with the guide.
          y = pos$y,
          labelText = paste("Node", new_id)
        ),
        ports = g6_ports(
          g6_port(
            key = sprintf("input-%s", new_id),
            type = "input",
            placement = "left",
            fill = "#52C41A",
            r = 4
          ),
          g6_port(
            key = sprintf("output-%s", new_id),
            type = "output",
            placement = "right",
            fill = "#FF4D4F",
            r = 4
          )
        )
      )) |>
      g6_add_edges(
        g6_edge(
          source = input[["dag-selected_port"]][["node"]],
          target = new_id,
          style = list(
            sourcePort = input[["dag-selected_port"]][["port"]],
            targetPort = sprintf("input-%s", new_id),
            endArrow = TRUE
          )
        )
      )
  })
}

shinyApp(ui, server)

Conclusion

Ports bring a new level of precision and flexibility to workflow building in applications like blockr.dag. By allowing you to define exactly how data flows between nodes, and to control the number and type of connections, ports make it much easier to design complex analytical pipelines. Whether you’re joining tables, binding rows, or using other data transformations, ports help keep your workflows organized. We’re excited to see how you use these new capabilities!

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 blockr.dag or g6R repositories.