A powerful
open-source library
for graph layout.

Start building beautiful graphs immediately,
or configure your own algorithm for any use case.


npm install nodal

Composable

Nodal allows you to build graph layouts by assembling small, predictable pieces (like points, forces, and constraints) into more complex structures and behaviors.

Hackable

Nodal is designed with an appreciation of the diversity of graph layout needs. Its elegant, well-documented abstractions are easy to extend or replace for domain-specific applications.

Intuitive

Nodal is based on gradient-descent rather than the inscrutable algorithms of traditional layout. This allows you to leverage physical and geometric intuitions while tuning your graphs.

Interactive Demo

Try different configurations. Drag nodes around. View the code and data.
Nodes
Edges
Constraints
Demo Options
11-122-133-112345678910111213141516171819202122
import { NodeSchema, EdgeSchema } from 'nodal';

// A 'NodeSchema'/'EdgeSchema' is a lightweight
// object transforms into a full 'Node'/'Edge'.
const nodeSchemas: NodeSchema[] = [
  { id: "n1", shape: { type: "rectangle", width: 20, height: 20 } }, 
  { id: "n2", shape: { type: "rectangle", width: 20, height: 20 } }, 
  { id: "n3", shape: { type: "rectangle", width: 20, height: 20 } }, 
  { id: "n4", shape: { type: "rectangle", width: 40, height: 20 }, ports: { "east1": { location: "east" } } }, 
  { id: "n5", shape: { type: "circle", radius: 10 } }, 
  { id: "n6", shape: { type: "circle", radius: 10 } }, 
  { id: "n7", shape: { type: "circle", radius: 10 } }, 
  { id: "n8", shape: { type: "rectangle", width: 40, height: 20 } }, 
  { id: "n9", shape: { type: "rectangle", width: 20, height: 20 } }, 
  { id: "n10", shape: { type: "rectangle", width: 40, height: 20 } }, 
  { id: "n11", shape: { type: "rectangle", width: 60, height: 20 } }, 
  { id: "n12", shape: { type: "rectangle", width: 40, height: 20 }, ports: { "west1": { location: "west" }, "south1": { location: "south", "order": 2 }, "south2": { location: "south", "order": 1 }, "east1": { location: "east" } } }, 
  { id: "n13", shape: { type: "rectangle", width: 20, height: 20 } }, 
  { id: "n14", shape: { type: "rectangle", width: 20, height: 20 } }, 
  { id: "n15", shape: { type: "rectangle", width: 40, height: 20 } }, 
  { id: "n16", shape: { type: "rectangle", width: 20, height: 20 } }, 
  { id: "n17", shape: { type: "rectangle", width: 20, height: 20 } }, 
  { id: "n18", shape: { type: "rectangle", width: 20, height: 20 }, ports: { "west1": { location: "west" } } }, 
  { id: "n19", shape: { type: "rectangle", width: 20, height: 20 }, ports: { "west1": { location: "west" } } }, 
  { id: "n20", shape: { type: "rectangle", width: 20, height: 20 }, ports: { "west1": { location: "west" } } }, 
  { id: "n21", shape: { type: "rectangle", width: 20, height: 20 } }, 
  { id: "n22", shape: { type: "rectangle", width: 20, height: 20 } }, 
  { id: "p1", children: ["n1", "n2", "n3", "n4", "n8", "p1-1"] }, 
  { id: "p1-1", children: ["n5", "n6", "n7"] }, 
  { id: "p2", children: ["n9", "n10", "n11", "n12", "n13", "n14", "n15", "p2-1"], ports: { "east1": { location: "east" }, "west1": { location: "west" } } }, 
  { id: "p2-1", children: ["n16", "n17"] }, 
  { id: "p3", children: ["n18", "n19", "n20", "p3-1"] }, 
  { id: "p3-1", children: ["n21", "n22"], shape: { type: "circle", radius: 40 } }
];
const edgeSchemas: EdgeSchema[] = [
  { id: "e1->2", source: { id: "n1" }, target: { id: "n2" } }, 
  { id: "e1->3", source: { id: "n1" }, target: { id: "n3" } }, 
  { id: "e2->4", source: { id: "n2" }, target: { id: "n4" } }, 
  { id: "e3->4", source: { id: "n3" }, target: { id: "n4" } }, 
  { id: "e4->5", source: { id: "n4" }, target: { id: "n5" } }, 
  { id: "e4->6", source: { id: "n4" }, target: { id: "n6" } }, 
  { id: "e4->7", source: { id: "n4" }, target: { id: "n7" } }, 
  { id: "e5->8", source: { id: "n5" }, target: { id: "n8" } }, 
  { id: "e6->8", source: { id: "n6" }, target: { id: "n8" } }, 
  { id: "e7->8", source: { id: "n7" }, target: { id: "n8" } }, 
  { id: "e9->10", source: { id: "n9" }, target: { id: "n10" } }, 
  { id: "e10->11", source: { id: "n10" }, target: { id: "n11" } }, 
  { id: "e11->12", source: { id: "n11" }, target: { id: "n12" } }, 
  { id: "e12->13", source: { id: "n12", port: "south1" }, target: { id: "n13" } }, 
  { id: "e12->14", source: { id: "n12", port: "south2" }, target: { id: "n14" } }, 
  { id: "e13->15", source: { id: "n13" }, target: { id: "n15" } }, 
  { id: "e14->15", source: { id: "n14" }, target: { id: "n15" } }, 
  { id: "e16->17", source: { id: "n16" }, target: { id: "n17" }, meta: { flow: "east" } }, 
  { id: "e18->19", source: { id: "n18" }, target: { id: "n19" } }, 
  { id: "e19->20", source: { id: "n19" }, target: { id: "n20" } }, 
  { id: "e19->21", source: { id: "n19" }, target: { id: "n21" }, meta: { flow: "east" } }, 
  { id: "e21->22", source: { id: "n21" }, target: { id: "n22" } }, 
  { id: "e4->p2", source: { id: "n4", port: "east1" }, target: { id: "p2", port: "west1" }, meta: { flow: "east", "length": 1.5 } }, 
  { id: "ep2->12", source: { id: "p2", port: "west1" }, target: { id: "n12", port: "west1" }, meta: { flow: "east" } }, 
  { id: "e12->p2", source: { id: "n12", port: "east1" }, target: { id: "p2", port: "east1" }, meta: { flow: "east" } }, 
  { id: "ep2->18", source: { id: "p2", port: "east1" }, target: { id: "n18", port: "west1" }, meta: { flow: "east" } }, 
  { id: "ep2->19", source: { id: "p2", port: "east1" }, target: { id: "n19", port: "west1" }, meta: { flow: "east" } }, 
  { id: "ep2->20", source: { id: "p2", port: "east1" }, target: { id: "n20", port: "west1" }, meta: { flow: "east" } }, 
  { id: "e15->p2-1", source: { id: "n15" }, target: { id: "p2-1" } }
];
const alignments = [
  { ids: ["n4", "n12", "n19", "n21"], axis: [1, 0] }, 
  { ids: ["n21", "n22"], axis: [0, 1] }
];
import {
  fromSchema,
  StructuredStorage,
  StagedLayout,
  BasicOptimizer,
  BooleanScheduler,
  generateSpringForces,
  generateNodeChildrenConstraints,
  generateNodePortConstraints,
  generateNodeAlignmentConstraints,
  nudgeAngle,
  constrainNodeOffset,
  constrainNodeNonoverlap,
} from 'nodal';

// Unspecified properties are filled in with sensible defaults,
// e.g. random initalization of node positions.
const { nodes, edges } = fromSchema(nodeSchemas, edgeSchemas)

// A 'Storage' allows easy and efficient lookup, iteration, and
// traversal over graph elements.
const storage = new StructuredStorage(nodes, edges);
const shortestPath = storage.shortestPaths();

// A 'Scheduler' sets a boolean/numeric value over time.
const orientationScheduler = new BooleanScheduler(true).for(50, false);
const flowScheduler = new BooleanScheduler(true).for(20, false);
const nonoverlapScheduler = new BooleanScheduler(true).for(30, false);
const alignmentScheduler = new BooleanScheduler(true).for(50, false);
 
// A 'Layout' performs the graph layout procedure on 'start()',
// e.g. a 'StagedLayout' procedure is broken up into different
// stages, each repeating some number of iterations.
const layout = new StagedLayout(
  storage,
  { steps: 200 },
  { // 'Force' stage that nudges elements around.
    iterations: 1,
    optimizer: new BasicOptimizer(0.5),
    fn: function* (storage, step, iter) {
      
      // Spring model attempts to reach ideal distance between
      // nodes based on shortest path length.
      yield* generateSpringForces(
        storage,
        kIdealLength,
        shortestPath,
      );
      
      // Snap edge angles to the closest of the given values.
      if(orientationScheduler.get(step)) {
        for(let edge of storage.edges()) {
          yield nudgeAngle(
            edge.source.node.center,
            edge.target.node.center,
            [0, 45, 90, 135, 180, 225, 270, 315],
            kOrientationStrength,
          );
        }
      }
      
    },
  },
  { // 'Constraint' stage that satisfies constraints.
    iterations: 5,
    optimizer: new BasicOptimizer(),
    fn: function* (storage, step, iter) {
      
      // Ensure edges flow in a particular direction.
      if(flowScheduler.get(step)) {
        for (let e of storage.edges()) {
          if (!storage.hasAncestor(e.source.node, e.target.node) &&
              !storage.hasAncestor(e.target.node, e.source.node)) {
            yield constrainNodeOffset(
              e.source.node, e.target.node, ">=", kFlowSeparation,
              e.meta && e.meta.flow === "east" ? [1, 0] : [0, 1],
            );
          }
        }
      }
       
      // Ensure boundaries of nodes do not overlap.
      if(nonoverlapScheduler.get(step)) {
        for (let u of storage.nodes()) {
          for(let sibling of storage.siblings(u)) {
              yield constrainNodeNonoverlap(u, sibling);
          }
        }
      }
       
      // Ensure specified nodes are aligned along the given axis.
      if(alignmentScheduler.get(step)) {
        for(let { ids, axis } of alignments) {
          yield* generateNodeAlignmentConstraints(
            ids.map((id) => storage.node(id)), axis,
          );
        }
      }
      
      // Ensure nodes contain their children and that ports are
      // placed on the correct location of the boundary.
      for (let u of storage.nodes()) {
        yield* generateNodeChildrenConstraints(u, kNodePadding);
        yield* generateNodePortConstraints(u);
      }

    },
  },
);
layout.start();

// Useful info (e.g. node positions/sizes) is accessed with
// 'storage.nodes()', 'storage.edges()', 'storage.bounds()', etc.

Maintainers

...

Nikhil Bhattasali
Core Developer

...

Ryan Holmdahl
Core Developer