Building a flow editor

Bringing our network tab to life with zero dependencies

Written by

Oguzhan Olguncu

Published on

When we started building the network tab for our deployment platform, we wanted users to understand their API architecture at a glance: how traffic flows from the internet through gateways to instances, where bottlenecks occur, and which nodes are healthy or failing.

We wanted to ship this quickly with minimal effort, but existing libraries like React Flow aren't easy to configure. They offer fine-grained control over everything, but you still have to learn the ins and outs. Not just the person implementing it, but the entire team maintaining it needs to understand the framework. That's a deal breaker for us.

We also didn't need draggable nodes, re-attachable connection lines, or other fancy features. We just needed customizable nodes, clean connection lines, and proper hierarchical layouts.

So the decision was simple, we had to roll our own flow logic and components.

But, how?

What we needed was:

  • Animated grid-like background
  • Canvas-like structure for placing elements freely anywhere, for both nodes and overlay items (to show details for selected nodes)
  • An engine that takes a tree-like structure and outputs non-overlapping positions for each node, then tells the UI which nodes to connect
  • Simple way to style nodes without diving into the underlying code

Our main goal was to design an isolated piece of code that requires minimal maintenance and allows people to freely build on top of it.

We also had to decide between SVG and Canvas. Since we needed an easier way to control the DOM, we went with SVG, as we didn't have any performance-heavy requirements

Design of our infinite canvas

This is where we put all the canvas logic don't let the name deceive you, it's still SVG. It allows us to pan, zoom, and display our dotty background.

The API looks like this:

1type InfiniteCanvasProps = {
2  minZoom?: number;
3  maxZoom?: number;
4  defaultZoom?: number;
5  zoomSpeed?: number;
6  children: React.ReactNode;
7  overlay?: React.ReactNode;
8};

Since this accepts children, we can compose it however we want as long as it's proper SVG. The canvas also has constraints like maxZoom, minZoom, etc.

For every canvas operation, we use this state:

1type CanvasState = {
2  scale: number;
3  offset: Point;
4};

The scale helps us with zooming and offset with positioning thanks to this:

1const transform = `translate(${canvas.offset.x},${canvas.offset.y})scale(${canvas.scale})`;

We pass this to the g SVG element. So technically, we don't zoom, we just make elements smaller and bigger.

Panning Logic

For moving we save the gap between mouse and origin at click, then maintain that exact gap by calculating newOrigin = currentMouse - savedGap on every mouse move.

Here's what happens under the hood:

1Canvas origin at: (200, 100)
2
3Mouse Down at (250, 180):
4  startPanRef = (250 - 200, 180 - 100) = (50, 80)
5  Gap: 50px right, 80px down
6
7Mouse Move to (300, 200):
8  new origin = (300 - 50, 200 - 80) = (250, 120)
9  Mouse moved +50, +20
10  Origin moved +50, +20

This keeps the canvas glued to your cursor at the exact spot you grabbed it.

Zooming

For zooming we disable the native browser zoom. Otherwise, our canvas can't scale properly because the browser zooms before we actually scale stuff.

Grid Background

We render an animated dot grid using SVG patterns. Each dot pulses in size and opacity for visual interest.

Calculating the Layout

Now that we have a working canvas, we need to figure out where to place things. The layout system has two parts: a layout engine that calculates node positions and connection paths, and React components that render them.

The API looks like this:

1type TreeLayoutProps = {
2  data: Node;
3  onNodeClick?: (node: Node) => void;
4  renderNode: (node: Node, parent?: Node) => React.ReactNode;
5  renderConnection?: (
6    path: Point[],
7    parent: Node,
8    child: Node,
9  ) => React.ReactNode;
10};

The caller only needs to care about the data and how to style the things the layout engine produces.

Before we move forward, let's look at an example data:

1{
2    "id": "internet",
3    "label": "INTERNET",
4    "metadata": {
5        "type": "origin"
6    },
7    "children": [
8        {
9            "id": "us-east-1-gw-p9yq",
10            "label": "gw-p9yq",
11            "direction": "horizontal",
12            "metadata": {
13                "type": "gateway",
14            },
15            "children": [
16                {
17                    "id": "us-east-1-gw-p9yq-inst-1",
18                    "label": "instance-1",
19                    "metadata": {
20                        "type": "instance",
21                    }
22                }
23            ]
24        },
25        {
26            "id": "eu-central-1-gw-2944",
27            "label": "gw-2944",
28            "direction": "horizontal",
29            "metadata": {
30                "type": "gateway",
31            },
32            "children": [
33                {
34                    "id": "eu-central-1-gw-2944-inst-1",
35                    "label": "instance-1",
36                    "metadata": {
37                        "type": "instance",
38                    }
39                }
40            ]
41        }
42    ]
43}

The main challenge here is that we can have N number of gateways connected to internet and M number of instances connected to each gateway. We have to ensure there's no overlap and their directions are respected.

What the TreeLayout component does is:

  • Accept the data from the API
  • Call the layout engine and calculate positions and connection paths
  • Finally, iterate over the nodes and connections the engine produces

So our TreeLayout is basically just a shell, it calls the engine and glues everything together with React.

Flow of Layout Engine

The layout engine is where the magic happens. But before we can calculate anything, we need to know how big our nodes are. Since nodes are just components, we need their dimensions first. Right now we just hardcode the sizes since we know our node types ahead of time. Later, we could render them off-screen, measure them via DOM queries, and feed those dimensions to the engine. But that's overkill for now.

If you try to call calculate() before setting dimensions, the code panics. This is intentional, we want to catch errors during development. Otherwise, it's so easy to miss stuff, and later you start asking yourself, "what did I miss?"

Once we have dimensions, we call calculate() to figure out where everything goes. The core of this is buildNodeLayout(), which runs depth-first through the tree. The root node is easy: it's at level 0, position {x:0, y:0}.

Per-Node Direction Control

Here's a key design decision: each node controls how its children layout. The tree doesn't have one global direction, each node decides independently.

1{
2  id: "us-east-1",
3  direction: "horizontal",  // My children spread horizontally (side-by-side)
4  children: [...]
5}

This is what lets the internet node spread gateways horizontally while each gateway spreads its instances horizontally. Without this, you'd need separate layout passes or complex conditional logic. Instead, the engine asks each node 'how do your children layout?' and recurses.

Configuration Tunability

The engine exposes several tuning parameters:

  • horizontalIndent / verticalOffset: Fine-tune child positioning relative to parent
  • subtreeOverlap: Controls how much subtrees can overlap (0-1 range). Lower values pack things tighter, higher values give more breathing room
  • verticalSiblingSpacing: Multiplier for vertical spacing between siblings in vertical layouts

We ship sensible defaults, but if you need precise control over layout density or alignment, people can easily configure it through props.

Fail-Fast

We use invariants throughout the engine. If dimensions are missing, if a node reference is invalid, if anything's wrong, the code panics immediately:

1invariant(nodeDim, `Missing dimensions for node ${node.id}`);

This is extremely useful technique to catch things during development. We follow this ideology everywhere in our codebase. Even our Go codebase follows that philosophy.

Depth-First Traversal

Once the engine has everything it needs, buildNodeLayout() walks the tree depth-first. We start at the root, process it, then recursively dive into the first child's entire subtree before moving to the next sibling. For this tree:

1INTERNET
2          +-----------+-----------+
3          |                       |
4        gw-1                    gw-2
5          |                       |
6      +------+                +------+
7      |      |                |      |
8   inst-1 inst-2           inst-3 inst-4

We end up with:

1[
2  level 0: INTERNET
3  level 1: gw-1
4  level 2: inst-1
5  level 2: inst-2
6  level 1: gw-2
7  level 2: inst-3
8  level 2: inst-4
9]

Traversing the tree and assigning levels is the easy part. The hard part is preventing overlap.

Preventing Overlap

Each level needs enough space for its widest subtree plus spacing. We calculate this bottom-up with calculateSubtreeWidth() (and calculateSubtreeHeight() for vertical layouts).

Here's an example:

  • inst-1, inst-2, inst-3, inst-4: width = 282 (base case, leaf nodes)
  • gw-1 subtree: width = 282 + 25 + 282 = 589 (two instances + spacing)
  • gw-2 subtree: width = 282 + 25 + 282 = 589 (two instances + spacing)
  • INTERNET subtree: width = 589 + 25 + 589 = 1203 (both gateways + spacing)

Horizontal Stacking

Now that we know the subtree widths, we can calculate actual X positions. The goal is to center all children under their parent.

If there's only one child, it centers directly under the parent. Simple.

For multiple children, we need to distribute them evenly. Let's say the parent is at X=0 and we have two instances that need a total width of 589.

1gw-1 (X=0)
23        ┌───────────────┴───────────────┐
4        │       total width = 589       │
5        │                               │
6   -294.5                               +294.5
7    (start)                             (end)
8        │                               │
9        ├──────────┐         ┌──────────┤
10        │  inst-1  │         │  inst-2  │
11        │ (w=282)  │ spacing │ (w=282)  │
12        │          │  (25)   │          │
13        └──────────┘         └──────────┘
14          ▲                      ▲
15       -153.5                 +153.5
16      (center)               (center)
1Calculation breakdown:
2gw-1 at X=0
3Children: [inst-1(282), inst-2(282)]
4spacing = 25
5
6Total width: 282 + 25 + 282 = 589
7Start position: 0 - 589/2 = -294.5
8
9inst-1 center: -294.5 + 282/2 = -153.5
10inst-2 center: -294.5 + 282 + 25 + 282/2 = 153.5

When a node has direction="horizontal", its children spread horizontally at the same Y level, creating this left-to-right arrangement.

Vertical Positioning

For nodes stacked vertically (when the parent has direction="vertical"), we use:

1childY = parentY + parentHeight/2 + spacing + childHeight/2

Let's visualize this with INTERNET → gateways:

1┌─────────┐
2│INTERNET │  y=0, height=20
3│ (center)│
4└────┬────┘
5     │ +10 (half of parent height, reach bottom edge)
67     │ +75 (spacing gap)
89     │ +50 (half of child height, reach center)
1011┌─────────┐
12│  gw-1   │  y=135, height=100
13│ (center)│
14└─────────┘

Calculation: 0 + 10 + 75 + 50 = 135

All siblings at the same level share the same Y coordinate since they spread horizontally. So gw-2 also sits at y=135, just at a different X position.

We could optimize this by calculating Y once per level instead of per-node, but for typical tree sizes the performance gain isn't worth the added complexity.

For vertical stacking within a group

1┌─────────┐
2│  gw-1   │  y=135, height=100
3└────┬────┘
4     │ +50 (half height, reach bottom)
5     │ +75 (spacing)
6     │ +50 (half of inst-1, reach center)
78┌─────────┐
9│ inst-1  │  y=310, height=100
10└────┬────┘
11     │ +50 (half height, reach bottom)
12     │ +75 (spacing)
13     │ +50 (half of inst-2, reach center)
1415┌─────────┐
16│ inst-2  │  y=485, height=100
17└─────────┘

Calculation:

1startY = 135 + 50 + 75 = 260
2inst-1 center: 260 + 50 = 310
3inst-2 center: 310 + 50 + 75 + 50 = 485

Connection Lines

Straight lines between nodes would overlap and look messy. Instead, we route connections through intermediate waypoints, think of them as turning points that guide the line around obstacles.

Connection lines attach to node edges, not centers. We calculate each node's left, right, top, and bottom edges from its center position and dimensions. This gives us exact attachment points for clean connections.

For horizontal layouts (children spreading side-by-side), we use a Z-shaped path:

1Parent
23  └──→
45  ←──┘
67Child

We calculate the midpoint of the vertical gap between parent's bottom and child's top. This creates symmetrical turns that look clean:

1const verticalGap = childEdges.top - parentEdges.bottom;
2const midY = parentEdges.bottom + verticalGap * 0.5;

This is a 4-point path:

1midY = parentEdges.bottom + (childEdges.top - parentEdges.bottom) * 0.5;
2[
3  { x: parentX, y: parentEdges.bottom },
4  { x: parentX, y: midY },
5  { x: childX, y: midY },
6  { x: childX, y: childEdges.top },
7];

For vertical layouts (children stacking top-to-bottom), we use a trunk-and-branch pattern:

1Parent ─┬─ Child1
2        ├─ Child2
3        └─ Child3

This creates a 3-point path:

1trunkX = parentEdges.left - trunkOffset;
2[
3  { x: trunkX, y: parentY }, // Move to trunk
4  { x: trunkX, y: childY }, // Down trunk to child's Y
5  { x: childEdges.left, y: childY }, // Branch to child
6];

All siblings share the same trunkX, creating that unified tree look.

And that's it. The engine spits out positioned nodes and connection paths.

Rendering the Connections

Once we have the waypoint paths, we need to actually draw them. First, let us show you how the raw waypoints look and you'll understand why we built something better.

raw-waypoints

See the red dots? Each one is a waypoint calculated by the layout engine.

The solution is simple (it's not SVG math sucks), detect corners where the path changes direction (horizontal-to-vertical or vice versa), then replace those sharp angles with curves. We calculate entry and exit points around each corner and use the corner itself as the curve's control point. This creates natural-looking turns.

For animation, we use SVG's stroke-dasharray and animate stroke-dashoffset. This creates the moving dots/dashes effect without any JS animation:

1<animate
2  attributeName="stroke-dashoffset"
3  to={`-${dashTotal}`}
4  from="0"
5  dur={`${speed}s`}
6  repeatCount="indefinite"
7/>
styled-waypoints

What We Shipped

It handles our network tab perfectly: gateways spreading horizontally from the internet, instances spreading horizontally from gateways, smooth animated connections showing traffic flow. The entire team understands how it works. When we need to change something, we just change it.

Trade-offs We Made

We hardcode node dimensions because we know our node types ahead of time. A general-purpose library would need to render nodes off-screen, measure them, then feed those dimensions to the engine. Too much complexity for our use case.

The routing logic is baked in for our specific tree structure. If someone wanted to use this for an org chart or file system tree, they'd need to modify the engine. This is designed specifically for our deploy network.

Should You Roll Your Own?

Probably not. React Flow is excellent if you need the full feature set and don't mind the learning curve.

But if you know exactly what you need, go ahead. We shipped this fairly quick with zero dependencies, and the entire team understands how it works.

Protect your API.
Start today.

150,000 requests per month. No CC required.