Flow Connect
A lightweight yet powerful library for creating node-based visual programming interfaces.
Quick Start
FlowConnect is a visual programming framework for creating node-based interfaces that are reactive, event-driven, customizable and executable.
Installation
npm install --save flow-connect
<script src="https://cdn.jsdelivr.net/npm/flow-connect@latest/dist/flow-connect.js"></script>
Learning FlowConnect is as simple as understanding what these terms mean and how they are related: Flows, Nodes, Connectors, Groups and Sub-Flows.
Every FlowConnect instance that you create has one or more Flows, and every Flow consists of Nodes, Connectors and Groups, Flows can also have Sub-Flows.
Create a Flow, create a bunch of Nodes inside that flow, connect those nodes using Connectors, group similar nodes together using Node Groups and if your flow becomes large, create Sub-Flows
Example
quick-start.js
import { FlowConnect } from "flow-connect";
import { Vector, Node } from "flow-connect/core";
// Create an instance of FlowConnect by passing it a reference of <div> or <canvas> element
let flowConnect = new FlowConnect(document.getElementById("canvas"));
// Create a Flow (which is a container of nodes that you will create)
let flow = flowConnect.createFlow({ name: "Basic Example", rules: {} });
/* Create a custom node */
class CustomTimerNode extends Node {
timerId = -1;
setupIO() {
this.addTerminals([{ type: TerminalType.OUT, name: "trigger", dataType: "event" }]);
}
created(options) {
const { interval = 1000 } = options;
this.state = { interval };
this.width = 100;
this.flow.on("start", () => {
this.outputs[0].emit();
this.timerId = setInterval(() => this.outputs[0].emit(), this.state.interval);
});
this.flow.on("stop", () => clearInterval(this.timerId));
}
process() {}
}
/* Register this new custom node with a unique name */
FlowConnect.register({ type: "node", name: "my-custom/timer-node" }, CustomTimerNode);
/* Create new node using previously registered node type */
let timerNode = flow.createNode("my-custom/timer-node", Vector.create(45, 7), { width: 500 });
/* Or, you can create a node using generic 'core/empty' type */
let randomNode = flow.createNode("core/empty", Vector.create(285, 50), {
name: "Random",
width: 120,
inputs: [{ name: "trigger", dataType: "event" }],
outputs: [{ name: "random", dataType: "number" }],
});
randomNode.inputs[0].on("event", () => {
randomNode.setOutputs({ random: Math.random() });
});
let multiplyNode = flow.createNode("core/empty", Vector.create(552, 76), {
name: "Multiply",
width: 100,
inputs: [
{ name: "a", dataType: "number" },
{ name: "b", dataType: "number" },
],
outputs: [{ name: "result", dataType: "number" }],
});
multiplyNode.on("process", () => {
let a = multiplyNode.getInput("a");
let b = multiplyNode.getInput("b");
multiplyNode.setOutputs({ result: a * b });
});
/* There are also a whole set of pre-configured nodes for specific uses */
let numberSource = flow.createNode("common/number-source", Vector.create(245, 128), {
state: { value: 100 },
});
let labelNode = flow.createNode("core/empty", Vector.create(755, 119), { name: "Label", width: 120 });
labelNode.ui.append(
labelNode.createUI("core/label", { text: "", input: true, style: { precision: 2, fontSize: "14px" } })
);
// Connect all the nodes
timerNode.outputs[0].connect(randomNode.inputs[0]);
randomNode.outputs[0].connect(multiplyNode.inputs[0]);
numberSource.outputs[0].connect(multiplyNode.inputs[1]);
multiplyNode.outputs[0].connect(labelNode.inputsUI[0]);
/* Specify which Flow to render on the canvas
* you can create any number of flows, but only one can be rendered at a time */
flowConnect.render(flow);
// Now you can go ahead and run this example ^
Customizable
In FlowConnect, almost anything can be customized, from individual styling of nodes, terminals, connectors, and groups to UIs inside each node, or tap into the render pipeline and take over entirely!
custom-example.js
import { FlowConnect } from "flow-connect";
import { Vector } from "flow-connect/core";
let flowConnect = new FlowConnect(document.getElementById("canvas"));
let flow = flowConnect.createFlow({
name: "Customize Example",
// Customize colors for each terminal type of every node in this Flow
ruleColors: {
event: "#ff8787",
number: "#b7ff87",
boolean: "#87afff",
string: "#ff87c9",
any: "red",
},
});
let timerNode1 = flow.createNode("common/timer", Vector.create(22.6, 1.2), {
state: { delay: 700 },
// Customization applied only on this node
style: {
padding: 15,
spacing: 20,
rowHeight: 20,
color: "#555",
titleColor: "green",
titleHeight: 30,
outlineColor: "#ffa200",
terminalRowHeight: 25,
terminalStripMargin: 4,
maximizeButtonColor: "#df87ff",
expandButtonColor: "blueviolet",
minimizedTerminalColor: "#87dfff",
nodeButtonSize: 10,
nodeButtonSpacing: 10,
},
});
// Styling this Node
timerNode1.ui.style = {
backgroundColor: "#6ba4ff",
shadowColor: "white",
shadowBlur: 0,
shadowOffset: Vector.Zero(),
borderColor: "#0062ff",
borderWidth: 8,
};
// Styling individual UI components
let label = timerNode1.ui.query("core/input")[0].children[0];
label.style.backgroundColor = "#fff";
label.style.color = "#000";
let timerNode2 = flow.createNode("common/timer", Vector.create(22.6, 194.7), {
state: { delay: 600 },
});
timerNode2.ui.style = {
backgroundColor: "#ffb561",
shadowColor: "#999",
shadowBlur: 10,
shadowOffset: Vector.Zero(),
borderColor: "#ffb561",
borderWidth: 0,
};
let randomNode = flow.createNode("common/random", Vector.create(321.5, 6.7), {
state: { min: 0, max: 5 },
});
randomNode.ui.style.backgroundColor = "#f7ff99";
let labelStyle = { color: "#547053", font: "courier" };
randomNode.ui.query("core/label").forEach((lbl) => Object.assign(lbl.style, labelStyle));
randomNode.ui.query("core/input").forEach((input) => (input.children[0].style.backgroundColor = "#abff45"));
let customNode = flow.createNode("core/empty", Vector.create(615.3, 79.8), {
name: "Custom",
width: 200,
state: { preset: "default", renderer: 0 },
});
let select = customNode.createUI("core/select", {
values: ["default", "dark", "transparent", "red", "green"],
propName: "preset",
height: 15,
input: true,
style: { fontSize: "13px", grow: 1 },
});
let button = customNode.createUI("core/button", { text: "Custom Renderers", input: true });
customNode.ui.append([
customNode.createUI("core/x-layout", {
childs: [customNode.createUI("core/label", { text: "Preset" }), select],
style: { spacing: 15 },
}),
button,
]);
let labels = customNode.ui.query("core/label");
let selects = customNode.ui.query("core/select");
let lightStyle = () => {
labels.forEach((label) => (label.style.color = "#000"));
selects.forEach((select) => (select.style.arrowColor = "#000"));
button.style.backgroundColor = "#666";
button.children[0].style.color = "#fff";
};
let darkStyle = () => {
labels.forEach((label) => (label.style.color = "#fff"));
selects.forEach((select) => (select.style.arrowColor = "#fff"));
button.style.backgroundColor = "#fff";
button.children[0].style.color = "#000";
};
customNode.watch("preset", (_oldVal, newVal) => {
if (newVal === "dark") darkStyle();
else lightStyle();
switch (newVal) {
case "default":
customNode.ui.style.backgroundColor = "#ddd";
break;
case "dark":
customNode.ui.style.backgroundColor = "#000";
break;
case "transparent":
customNode.ui.style.backgroundColor = "transparent";
break;
case "red":
customNode.ui.style.backgroundColor = "#ff8080";
break;
case "green":
customNode.ui.style.backgroundColor = "#b1ff80";
break;
default:
return;
}
});
button.on("click", () => (customNode.state.renderer = (customNode.state.renderer + 1) % 3));
customNode.ui.style.shadowOffset = Vector.Zero();
customNode.ui.style.shadowBlur = 20;
customNode.ui.style.borderWidth = 0;
let renderers = [
(context, params) => {
/* ... (omitted for simplicity, copy this demo to grab full code) */
},
(context, params) => {
/*...*/
},
(context, params) => {
/*...*/
},
];
// 'renderers' are functions which return a user-defined renderer.
// This is helpful if you need a complex renderer and full control over how a
// node, connector or terminal should be rendered.
// It can be scoped to FlowConnect, Flow or individual Nodes, Groups or Connectors.
// In the below example, the custom renderer has been scoped to the node 'customNode'
// that we created above, which means the 'renderer' this function returns will only
// affect our 'customNode' Node.
customNode.renderers.background = (container) => {
return (context, params) => {
Object.assign(context, {
fillStyle: container.style.backgroundColor,
strokeStyle: container.style.borderColor,
shadowColor: container.style.shadowColor,
shadowBlur: container.style.shadowBlur,
lineWidth: container.style.borderWidth,
shadowOffsetX: container.style.shadowOffset.x,
shadowOffsetY: container.style.shadowOffset.y,
});
renderers[customNode.state.renderer](context, params);
};
};
let customRenderFn1 = (context, params, connector) => {
/*...*/
};
let customRenderFn2 = (context, params, connector) => {
/*...*/
};
// Here, we have a 'renderer' which is scoped to entire FlowConnect instance,
// and will affect every connector in every Flow.
flowConnect.renderers.connector = (connector) => {
if (
(connector.start && connector.start.dataType) === "event" ||
(connector.end && connector.end.dataType) === "event"
)
return customRenderFn1;
};
flow.renderers.connector = (connector) => {
if (connector.start && connector.start.node === timerNode2) return customRenderFn2;
};
timerNode1.outputs[0].connect(randomNode.inputs[0]);
randomNode.outputs[0].connect(customNode.inputsUI[0]);
timerNode2.outputs[0].connect(customNode.inputsUI[1]);
flowConnect.render(flow);
Event-driven
Using the good old pub-sub pattern .on | .off
, a wide range of events can be listened to, e.g. changing dimensions, flow execution, processing a node, new/updated input or output, UI updates, render cycles, and much more
event-example.js
import { FlowConnect } from 'flow-connect';
import { Vector } from 'flow-connect/core';
let flowConnect = new FlowConnect(document.getElementById('canvas'));
let flow = flowConnect.createFlow({ name: "Events Example" });
let timer1 = flow.createNode('common/timer', Vector.create(39.6, 5.1), {
state: {
delay: 500, lastBlink: 0, isBlinking: false, blinkDuration: 20,
emitValue: "Event from Timer1"
}
});
let timer2 = flow.createNode('common/timer', Vector.create(39.6, 126.8), {
state: {
delay: 1000, lastBlink: 0, isBlinking: false, blinkDuration: 20,
emitValue: "Event from Timer2"
}
});
let timer3 = flow.createNode('common/timer', Vector.create(304.3, 194), {
state: {
delay: 200, lastBlink: 0, isBlinking: false, blinkDuration: 20,
emitValue: "Event from Timer3"
}
});
let sync1 = flow.createNode('common/sync-event', Vector.create(262.8, 57.8), {
state: { lastBlink: 0, isBlinking: false, blinkDuration: 20 }
});
let sync2 = flow.createNode('common/sync-event', Vector.create(526.7, 118.8), {
state: { lastBlink: 0, isBlinking: false, blinkDuration: 20 }
});
let outputNode = flow.createNode("core/empty", Vector.create(746.7, 82.8), {
name: 'Output',
width: 110,
state: { isLogEnabled: false },
inputs: [{ name: "out", dataType: "event" }],
});
outputNode.ui.append(outputNode.createUI('core/x-layout', {
childs: [
outputNode.createUI('core/label', { text: "Log output ?" }),
outputNode.createUI('core/toggle', { height: 10, propName: "isLogEnabled", style: { grow: 1 } }),
],
style: { spacing: 5 }
}));
// Listening to terminals 'event' event
outputNode.inputs[0].on("event", (_terminal, data) => {
if (outputNode.state.isLogEnabled) console.log(data);
});
timer1.outputs[0].connect(sync1.inputs[0]);
timer2.outputs[0].connect(sync1.inputs[1]);
sync1.outputs[0].connect(sync2.inputs[0]);
timer3.outputs[0].connect(sync2.inputs[1]);
sync2.outputs[0].connect(outputNode.inputs[0]);
let handleEmitEvent = (terminal) => {
let currTime = flowConnect.time;
if (!terminal.node.state.isBlinking) {
[...terminal.node.nodeButtons.values()][1].style.color = "#000";
terminal.node.state.lastBlink = currTime + terminal.node.state.blinkDuration;
terminal.node.state.isBlinking = !terminal.node.state.isBlinking;
}
};
flow.nodes.forEach((node) => {
if (!node.outputs[0]) return;
let statusBlip = node.addNodeButton(() => null,
(context, params, nodeButton) => {
/* user-defined renderer, omitted for simplicity, copy this demo to grab full code */
},
Align.Right
);
// Event for this node-button's every render cycle
statusBlip.on("render", (nodeButton) => {
let state = nodeButton.node.state;
if (flowConnect.time - state.lastBlink > state.blinkDuration) {
nodeButton.style.color = "transparent";
state.isBlinking = false;
}
});
if (node.outputs[0]) node.outputs[0].on("emit", terminal => handleEmitEvent(terminal));
});
// The global stop event fired on FlowConnect's instance
flowConnect.on("stop", () =>
flow.nodes.forEach(node => {
if (node.nodeButtons.size === 2)
[...node.nodeButtons.values()][1].style.color = "transparent";
})
);
flowConnect.render(flow);
Reactive
Every node has its own reactive state, is two-way bindable with any input/output or UI component, and can be watched for changes
reactive-example.js
import { FlowConnect } from "flow-connect";
import { Vector } from "flow-connect/core";
let flowConnect = new FlowConnect(document.getElementById("canvas"));
let flow = flowConnect.createFlow({ name: "Reactivity Example" });
let stringSource = flow.createNode("common/string-source", Vector.create(41.1, -3.5), {
// Any node can have a reactive state
state: { value: "Sample String" },
});
let numberSource = flow.createNode("common/number-source", Vector.create(46.8, 93.2), {
state: { value: 100 },
});
let booleanSource = flow.createNode("common/boolean-source", Vector.create(60.3, 218), {
state: { value: false },
});
let log = flow.createNode("common/log", Vector.create(665.1, 64.3), {});
log.addNewTerminal("data");
log.addNewTerminal("data");
let customNode = flow.createNode("core/empty", Vector.create(369.1, 70.7), {
name: "Custom Node",
width: 170,
state: { stringValue: "", numberValue: 0, boolValue: false },
style: { spacing: 20 },
});
customNode.ui.append([
customNode.createUI("core/x-layout", {
childs: [
customNode.createUI("core/label", { text: "String", style: { grow: 0.4 } }),
customNode.createUI(
"core/label",
// Two-way binding works for any UI component, by passing the propName property
// In this example, this Label UI component is bound to 'stringValue' prop of customNode's state
{ text: customNode.state.stringValue, propName: "stringValue", input: true, output: true, style: { grow: 0.6 } }
),
],
style: { spacing: 10 },
}),
customNode.createUI("core/x-layout", {
childs: [
customNode.createUI("core/label", { text: "Number", style: { grow: 0.4 } }),
customNode.createUI("core/input", {
value: customNode.state.numberValue,
propName: "numberValue",
input: true,
output: true,
height: 20,
style: { type: InputType.Number, grow: 0.6 },
}),
],
style: { spacing: 10 },
}),
customNode.createUI("core/x-layout", {
childs: [
customNode.createUI("core/label", { text: "Boolean", style: { grow: 0.4 } }),
customNode.createUI("core/toggle", {
propName: "boolValue",
input: true,
output: true,
height: 10,
style: { grow: 0.2 },
}),
],
style: { spacing: 10 },
}),
]);
// Watchers can be registered for a state prop
customNode.watch("stringValue", (oldVal, newVal) =>
console.log("Watcher for prop 'stringValue': Old:", oldVal, "New:", newVal)
);
customNode.watch("numberValue", (oldVal, newVal) =>
console.log("Watcher for prop 'numberValue': Old:", oldVal, "New:", newVal)
);
customNode.watch("boolValue", (oldVal, newVal) =>
console.log("Watcher for prop 'boolValue': Old:", oldVal, "New:", newVal)
);
stringSource.outputsUI[0].connect(customNode.inputsUI[0]);
numberSource.outputsUI[1].connect(customNode.inputsUI[1]);
booleanSource.outputsUI[0].connect(customNode.inputsUI[2]);
customNode.outputsUI[0].connect(log.inputs[1]);
customNode.outputsUI[1].connect(log.inputs[2]);
customNode.outputsUI[2].connect(log.inputs[3]);
flowConnect.render(flow);
Executable
In FlowConnect, every Flow is executable and nodes are processed based on their dependencies, every Flow dynamically constructs/updates an internal 'graph' of what is shown on screen but optimized and ready for execution
executable-example.js
import { FlowConnect } from "flow-connect";
import { Vector, Node } from "flow-connect/core";
let flowConnect = new FlowConnect(document.getElementById("canvas"));
let flow = flowConnect.createFlow({ name: "Graph Execution Example" });
class DummyNode extends Node {
setupIO() {
this.addTerminals([
{ type: TerminalType.IN, name: "in", dataType: "any" },
{ type: TerminalType.OUT, name: "out", dataType: "any" },
]);
}
created() {
this.state = { status: "Stopped", lastProcessed: -1 };
this.width = 120;
this.setupUI();
this.button.on("click", () => this.setOutputs(0, "Dummy Data"));
this.on("process", () => {
this.state.status = "Processing";
this.state.lastProcessed = flow.flowConnect.time;
this.setOutputs(0, "Dummy Data");
});
this.flow.on("stop", () => (this.state.status = "Stopped"));
this.flow.on("tick", () => {
if (this.state.status === "Processing") {
if (flow.flowConnect.time - this.state.lastProcessed > 250) this.state.status = "Idle";
}
});
}
process() {}
setupUI() {
this.style.color = "white";
Object.assign(this.ui.style, { backgroundColor: "black", shadowBlur: 10, shadowColor: "black" });
this.button = this.createButton("Trigger output", {
height: 20,
style: { backgroundColor: "white", color: "black", shadowColor: "grey" },
});
this.ui.append([
this.createHozLayout([
this.createLabel("Status:", { style: { grow: 0.4, color: "white" } }),
this.createLabel(this.state.status, {
propName: "status",
style: { grow: 0.6, color: "white", align: Align.Right },
}),
]),
this.button,
]);
}
}
FlowConnect.register({ type: "node", name: "my-custom/dummy-node" }, DummyNode);
flow.renderers.background = () => {
return (context, params, target) => {
Object.assign(context, {
fillStyle: target.style.backgroundColor,
shadowBlur: target.style.shadowBlur,
shadowColor: target.style.shadowColor,
});
context.fillRect(params.position.x, params.position.y, params.width, params.height);
};
};
let positions = [
Vector.create(510.9, 18),
Vector.create(-298, 82.7),
Vector.create(-96.4, 21.1),
Vector.create(-95.5, 182),
Vector.create(105.2, -62),
Vector.create(105.9, 76.3),
Vector.create(104.4, 221.1),
Vector.create(302, 19.5),
Vector.create(304.4, 153.2),
Vector.create(512, -105.5),
Vector.create(505.1, 153.2),
Vector.create(720.3, 90),
];
for (let i = 0; i < 12; i++) {
flow.createNode("my-custom/dummy-node", positions[i], { name: "Node " + (i + 1) });
}
let nodes = [...flow.nodes.values()];
nodes[5].addTerminal(new Terminal(nodes[5], TerminalType.IN, "any", "in1"));
nodes[7].addTerminal(new Terminal(nodes[7], TerminalType.IN, "any", "in1"));
nodes[11].addTerminal(new Terminal(nodes[11], TerminalType.IN, "any", "in1"));
nodes[0].outputs[0].connect(nodes[2].inputs[0]);
nodes[1].outputs[0].connect(nodes[2].inputs[0]);
nodes[1].outputs[0].connect(nodes[3].inputs[0]);
nodes[2].outputs[0].connect(nodes[4].inputs[0]);
nodes[2].outputs[0].connect(nodes[5].inputs[0]);
nodes[2].outputs[0].connect(nodes[6].inputs[0]);
nodes[3].outputs[0].connect(nodes[6].inputs[0]);
nodes[3].outputs[0].connect(nodes[8].inputs[0]);
nodes[4].outputs[0].connect(nodes[7].inputs[0]);
nodes[5].outputs[0].connect(nodes[7].inputs[0]);
nodes[5].outputs[0].connect(nodes[8].inputs[0]);
nodes[6].outputs[0].connect(nodes[9].inputs[0]);
nodes[7].outputs[0].connect(nodes[9].inputs[0]);
nodes[8].outputs[0].connect(nodes[10].inputs[0]);
nodes[9].outputs[0].connect(nodes[11].inputs[0]);
nodes[10].outputs[0].connect(nodes[11].inputs[0]);
nodes[4].outputs[0].connect(nodes[7].inputs[1]);
nodes[3].outputs[0].connect(nodes[5].inputs[1]);
nodes[0].outputs[0].connect(nodes[11].inputs[1]);
flowConnect.render(flow);