Nodal allows you to build graph layouts by assembling small, predictable pieces (like points, forces, and constraints) into more complex structures and behaviors.
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.
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.
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.