for interactive demo.
54 |
55 | Send pull requests on Github!
56 |
--------------------------------------------------------------------------------
/__tests__/basics.js:
--------------------------------------------------------------------------------
1 | const { render } = require("enzyme");
2 | const fbpGraph = require("fbp-graph");
3 | const TheGraph = require("../index.js");
4 |
5 | const parseFBP = fbpString =>
6 | new Promise((resolve, reject) =>
7 | fbpGraph.graph.loadFBP(
8 | fbpString,
9 | (err, graph) =>
10 | err instanceof fbpGraph.Graph
11 | ? resolve(err)
12 | : err ? reject(err) : resolve(graph)
13 | )
14 | );
15 |
16 | const dummyComponent = {
17 | inports: [
18 | {
19 | name: "in",
20 | type: "all"
21 | }
22 | ],
23 | outports: [
24 | {
25 | name: "out",
26 | type: "all"
27 | }
28 | ]
29 | };
30 |
31 | const name = "'42' -> CONFIG foo(Foo) OUT -> IN bar(Bar)";
32 | const library = { Foo: dummyComponent, Bar: dummyComponent };
33 |
34 | describe("Basics", function() {
35 | describe("loading a simple graph", function() {
36 | let rendered;
37 |
38 | beforeAll(async () => {
39 | const graph = await parseFBP(name);
40 | rendered = render(TheGraph.App({ graph, library }));
41 | });
42 |
43 | it("should render 2 nodes", () => {
44 | expect(rendered.find(".node")).toHaveLength(2);
45 | });
46 |
47 | it("should render 1 edge", () => {
48 | expect(rendered.find(".edge")).toHaveLength(1);
49 | });
50 |
51 | it("should render 1 IIP", () => {
52 | expect(rendered.find(".iip")).toHaveLength(1);
53 | });
54 | });
55 | });
56 |
--------------------------------------------------------------------------------
/__tests__/editing.js:
--------------------------------------------------------------------------------
1 | const { mount } = require("enzyme");
2 | const fbpGraph = require("fbp-graph");
3 | const TheGraph = require("../index.js");
4 |
5 | const parseFBP = fbpString =>
6 | new Promise((resolve, reject) =>
7 | fbpGraph.graph.loadFBP(
8 | fbpString,
9 | (err, graph) =>
10 | err instanceof fbpGraph.Graph
11 | ? resolve(err)
12 | : err ? reject(err) : resolve(graph)
13 | )
14 | );
15 |
16 | const dummyComponent = {
17 | inports: [
18 | {
19 | name: "in",
20 | type: "all"
21 | }
22 | ],
23 | outports: [
24 | {
25 | name: "out",
26 | type: "all"
27 | }
28 | ]
29 | };
30 |
31 | const name = "'42' -> CONFIG foo(Foo) OUT -> IN bar(Bar)";
32 | const library = { Foo: dummyComponent, Bar: dummyComponent };
33 |
34 | const simulate = (
35 | node,
36 | type,
37 | data = {},
38 | opts = { bubbles: true, cancelable: true }
39 | ) => node.dispatchEvent(Object.assign(new Event(type, opts), data));
40 |
41 | describe("Editor navigation", () => {
42 | let mounted, svg, raf;
43 |
44 | beforeEach(async () => {
45 | const graph = await parseFBP(name);
46 | raf = window.requestAnimationFrame = jest.fn();
47 | mounted = mount(TheGraph.App({ graph, library }));
48 | svg = mounted.getDOMNode().getElementsByClassName("app-svg")[0];
49 | });
50 |
51 | afterEach(() => mounted.unmount());
52 |
53 | describe("dragging on background", () => {
54 | it("should pan graph view", () => {
55 | const deltaX = 100;
56 | const deltaY = 200;
57 | expect(mounted.state("x")).toBe(0);
58 | expect(mounted.state("y")).toBe(0);
59 | simulate(svg, "panstart");
60 | simulate(svg, "panmove", { gesture: { deltaX, deltaY } });
61 | simulate(svg, "panend");
62 | expect(mounted.state("x")).toBe(deltaX);
63 | expect(mounted.state("y")).toBe(deltaY);
64 | });
65 | });
66 |
67 | describe("mouse scrolling up", () => {
68 | it("should zoom in", () => {
69 | const deltaY = -100;
70 | expect(mounted.state("scale")).toBe(1);
71 | svg.onwheel = null;
72 | simulate(svg, "wheel", { deltaY });
73 | expect(raf).toHaveBeenCalledTimes(1);
74 | raf.mock.calls[0][0]();
75 | expect(mounted.state("scale")).toBe(1.2);
76 | });
77 | });
78 |
79 | describe("mouse scrolling down", () => {
80 | it("should zoom out", () => {
81 | const deltaY = 100;
82 | expect(mounted.state("scale")).toBe(1);
83 | svg.onwheel = null;
84 | simulate(svg, "wheel", { deltaY });
85 | expect(raf).toHaveBeenCalledTimes(1);
86 | raf.mock.calls[0][0]();
87 | expect(mounted.state("scale")).toBe(0.8);
88 | });
89 | });
90 |
91 | describe("multitouch pinch", () => {
92 | it("should zoom in/out", () => {
93 | expect(mounted.state("scale")).toBe(1);
94 | const touches = [
95 | { target: svg, identifier: "0", clientX: 0, clientY: 0 },
96 | { target: svg, identifier: "1", clientX: 100, clientY: 100 }
97 | ];
98 | simulate(svg, "touchstart", { touches, changedTouches: touches });
99 | touches[1].clientX = 50;
100 | simulate(svg, "touchmove", { touches, changedTouches: [touches[1]] });
101 | simulate(svg, "touchend", { touches, changedTouches: touches });
102 | expect(mounted.state("scale")).toBe(0.7905694150420948);
103 | simulate(svg, "touchstart", { touches, changedTouches: touches });
104 | touches[1].clientX = 100;
105 | simulate(svg, "touchmove", { touches, changedTouches: [touches[1]] });
106 | simulate(svg, "touchend", { touches, changedTouches: touches });
107 | expect(mounted.state("scale")).toBe(1);
108 | });
109 | });
110 |
111 | describe("hovering an node", () => {
112 | it("should highlight node");
113 | });
114 | describe("hovering an edge", () => {
115 | it("should highlight edge");
116 | });
117 | describe("hovering exported port", () => {
118 | it("should highlight exported port");
119 | });
120 | describe("hovering node group", () => {
121 | it("should highlight the group");
122 | });
123 | });
124 |
125 | describe("Editor", () => {
126 | let mounted, svg, raf, selectedNodes, selectedEdges;
127 |
128 | beforeEach(async () => {
129 | selectedNodes = {};
130 | selectedEdges = {};
131 | const graph = await parseFBP(name);
132 | raf = window.requestAnimationFrame = jest.fn();
133 | mounted = mount(
134 | TheGraph.App({
135 | graph,
136 | library,
137 | onNodeSelection: (id, node, toggle) => {
138 | if (toggle) return (selectedNodes[id] = !selectedNodes[id]);
139 | selectedNodes = selectedNodes[id] ? {} : { [id]: true };
140 | }
141 | })
142 | );
143 | svg = mounted.getDOMNode().getElementsByClassName("app-svg")[0];
144 | });
145 |
146 | afterEach(() => mounted.unmount());
147 |
148 | describe("dragging on node", () => {
149 | it("should move the node", () => {
150 | const deltaX = 100;
151 | const deltaY = 200;
152 | expect(mounted.props().graph.nodes[0].metadata.x).toBe(0);
153 | expect(mounted.props().graph.nodes[0].metadata.y).toBe(0);
154 | const [node] = mounted.getDOMNode().getElementsByClassName("node");
155 | simulate(node, "panstart");
156 | simulate(node, "panmove", { gesture: { deltaX, deltaY } });
157 | simulate(node, "panend");
158 | raf.mock.calls.forEach(([c]) => c());
159 | expect(mounted.props().graph.nodes[0].metadata.x).toBe(108);
160 | expect(mounted.props().graph.nodes[0].metadata.y).toBe(216);
161 | });
162 | });
163 |
164 | describe("dragging on exported port", () => {
165 | it("should move the port");
166 | });
167 |
168 | describe("dragging from node port", () => {
169 | it("should start making edge", () => {
170 | const deltaX = 100;
171 | const deltaY = 200;
172 | expect(svg.getElementsByClassName("edge")).toHaveLength(1);
173 | const [port] = svg.getElementsByClassName("port");
174 | simulate(port, "panstart");
175 | simulate(port, "panmove", { gesture: { deltaX, deltaY } });
176 | raf.mock.calls.forEach(([c]) => c());
177 | expect(svg.getElementsByClassName("edge")).toHaveLength(2);
178 | });
179 | });
180 |
181 | describe("dropping started edge on port", () => {
182 | it("should connect the edge", () => {
183 | const deltaX = 100;
184 | const deltaY = 200;
185 | const nodes = [...svg.getElementsByClassName("node")];
186 | const [p1, p2] = nodes.map(
187 | (n, i) =>
188 | n
189 | .getElementsByClassName(i ? "outports" : "inports")[0]
190 | .getElementsByClassName("port")[0]
191 | );
192 | simulate(p1, "panstart");
193 | simulate(p1, "panmove", { gesture: { deltaX, deltaY } });
194 | simulate(p1, "panend");
195 | simulate(p2, "tap");
196 | raf.mock.calls.forEach(([c]) => c());
197 | expect(mounted.props().graph.edges).toHaveLength(2);
198 | });
199 | });
200 |
201 | describe("dropping started edge outside", () => {
202 | it("should not connect the edge", () => {
203 | const deltaX = 100;
204 | const deltaY = 200;
205 | const [p1] = svg
206 | .getElementsByClassName("node")[0]
207 | .getElementsByClassName("inports")[0]
208 | .getElementsByClassName("port");
209 | simulate(p1, "panstart");
210 | simulate(p1, "panmove", { gesture: { deltaX, deltaY } });
211 | simulate(p1, "panend");
212 | simulate(svg, "click");
213 | raf.mock.calls.forEach(([c]) => c());
214 | expect(mounted.props().graph.edges).toHaveLength(1);
215 | });
216 | });
217 |
218 | describe("clicking exported port", () => {
219 | it("does nothing");
220 | });
221 |
222 | describe("clicking unselected node", () => {
223 | it("should add node to selection", () => {
224 | expect(selectedNodes).toEqual({});
225 | simulate(svg.getElementsByClassName("node")[0], "tap");
226 | raf.mock.calls.forEach(([c]) => c());
227 | expect(selectedNodes).toEqual({ foo: true });
228 | });
229 | });
230 |
231 | describe("clicking selected node", () => {
232 | it("should remove node from selection", () => {
233 | selectedNodes = { foo: true };
234 | simulate(svg.getElementsByClassName("node")[0], "tap");
235 | raf.mock.calls.forEach(([c]) => c());
236 | expect(selectedNodes).toEqual({});
237 | });
238 | });
239 |
240 | describe("clicking unselected edge", () => {
241 | it("should add edge to selection");
242 | });
243 | describe("clicking selected edge", () => {
244 | it("should remove edge from selection");
245 | });
246 | describe("selected nodes", () => {
247 | it("are visualized with a bounding box");
248 | describe("when dragging the box", () => {
249 | it("moves all nodes in selection");
250 | });
251 | });
252 | describe("node groups", () => {
253 | it("are visualized with a bounding box");
254 | it("shows group name");
255 | it("shows group description");
256 | describe("when dragging on label", () => {
257 | it("moves all nodes in group");
258 | });
259 | describe("when dragging on bounding box", () => {
260 | it("does nothing");
261 | });
262 | });
263 | describe("right-click node", () => {
264 | it("should open menu for node");
265 | });
266 | describe("right-click node port", () => {
267 | it("should open menu for port");
268 | });
269 | describe("right-click edge", () => {
270 | it("should open menu for edge");
271 | });
272 | describe("right-click exported port", () => {
273 | it("should open menu for exported port");
274 | });
275 | describe("right-click node group", () => {
276 | it("should open menu for group");
277 | });
278 | describe("right-click background", () => {
279 | it("should open menu for editor");
280 | });
281 | describe("long-press", () => {
282 | it("should work same as right-click");
283 | });
284 | });
285 |
286 | describe("Editor menus", () => {
287 | describe("node menu", () => {
288 | it("shows node name");
289 | it("shows component icon");
290 | it("should have delete action");
291 | it("should have copy action");
292 | it("should show in and outports");
293 | describe("clicking port", () => {
294 | it("should start edge");
295 | });
296 | });
297 | describe("node port menu", () => {
298 | it("should have export action");
299 | });
300 | describe("edge menu", () => {
301 | it("shows edge name");
302 | it("should have delete action");
303 | });
304 | describe("exported port menu", () => {
305 | it("should have delete action");
306 | });
307 | describe("node selection menu", () => {
308 | it("should have group action");
309 | it("should have copy action");
310 | });
311 | describe("editor menu", () => {
312 | it("should have paste action");
313 | });
314 | });
315 |
--------------------------------------------------------------------------------
/__tests__/graphchanges.js:
--------------------------------------------------------------------------------
1 | describe("Graph changes", () => {
2 | describe("adding node", () => {
3 | it("should update editor");
4 | });
5 | describe("removing node", () => {
6 | it("should update editor");
7 | });
8 | describe("adding edge", () => {
9 | it("should update editor");
10 | });
11 | describe("removing edge", () => {
12 | it("should update editor");
13 | });
14 | describe("adding inport", () => {
15 | it("should update editor");
16 | });
17 | describe("removing inport", () => {
18 | it("should update editor");
19 | });
20 | describe("adding outport", () => {
21 | it("should update editor");
22 | });
23 | describe("removing outport", () => {
24 | it("should update editor");
25 | });
26 | });
27 |
--------------------------------------------------------------------------------
/__tests__/nav.js:
--------------------------------------------------------------------------------
1 | describe("Navigation", () => {
2 | it("renders a simplified version of graph");
3 | it("supports dark & light theme");
4 | it("supports specifying nodeSize");
5 | it("supports fill,stroke,edge styles");
6 | });
7 |
--------------------------------------------------------------------------------
/__tests__/thumbnail.js:
--------------------------------------------------------------------------------
1 | describe("Thumbnail", () => {
2 | it("renders a thumbnail of graph");
3 | it("visualizes the viewport area");
4 | it("allows to pan viewport");
5 | it("hides when whole graph is in view");
6 | });
7 |
--------------------------------------------------------------------------------
/bin/the-graph-render:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | // Node.js CLI tool for rendering an image of the graph
4 |
5 | const buffer = require('buffer');
6 | const fs = require('fs');
7 | const path = require('path');
8 | const http = require('http');
9 |
10 | const argv = require('yargs').argv
11 | const jsjob = require('jsjob');
12 |
13 | function unpackUrl(dataurl) {
14 | var mimetype = dataurl.substring(dataurl.indexOf(':'), dataurl.indexOf(';'));
15 | var encoding = dataurl.substring(dataurl.indexOf(';')+1, dataurl.indexOf(','));
16 | if (encoding != 'base64') {
17 | throw new Error('Dataurl must have base64 encoding, got ' + encoding);
18 | }
19 |
20 | var encoded = dataurl.substring(dataurl.indexOf(','), dataurl.length);
21 | var raw = buffer.Buffer.from(encoded, 'base64');
22 | return raw;
23 | }
24 |
25 | function setupJobServer(jobData, options, callback) {
26 | if (!options.port) { options.port = 9999; }
27 | if (!options.path) { options.path = '/the-graph-render.js'; }
28 |
29 | function onRequest(req, res) {
30 | if (req.url == options.path) {
31 | res.end(jobData);
32 | } else {
33 | res.writeHead(404);
34 | res.end();
35 | }
36 | }
37 |
38 | server = http.createServer(onRequest);
39 | server.listen(options.port, function(err) {
40 | return callback(err, server, options);
41 | });
42 | }
43 |
44 |
45 | function runRender(graphData, options, callback) {
46 | if (!options.job) { options.job = 'http://localhost:9999/the-graph-render.js'; }
47 |
48 | var runnerConfig = {
49 | verbose: options.verbose,
50 | };
51 | var runner = new jsjob.Runner(runnerConfig);
52 | runner.start(function(err) {
53 | if (err) return callback(err);
54 |
55 | runner.runJob(options.job, graphData, options, function(err, result, details) {
56 | if (err) { return callback(err); }
57 |
58 | runner.stop(function(err) {
59 | return callback(err, result);
60 | });
61 | });
62 | });
63 |
64 | }
65 |
66 | function render(graphPath, options, callback) {
67 | if (!options.format) { options.format = 'png' }
68 | if (!options.output) {
69 | options.output = graphPath.replace(path.extname(graphPath), '.'+options.format)
70 | }
71 |
72 | const p = path.join(__dirname, '../dist/the-graph-render.js');
73 | const defaultJobData = fs.readFileSync(p);
74 |
75 | fs.readFile(graphPath, 'utf-8', function(err, d) {
76 | if (err) { return callback(err) }
77 | try {
78 | graphData = JSON.parse(d);
79 | } catch (err) {
80 | return callback(err);
81 | }
82 |
83 | setupJobServer(defaultJobData, {}, function(err, server) {
84 | if (err) return callback(err);
85 |
86 | runRender(graphData, options, function (err, output, details) {
87 | if (err) { return callback(err); }
88 |
89 | if (output.indexOf('data:') == 0) {
90 | output = unpackUrl(output);
91 | }
92 |
93 | fs.writeFile(options.output, output, function(err) {
94 | return callback(err, options.output);
95 | });
96 | });
97 | });
98 | });
99 |
100 | }
101 |
102 | function main() {
103 | var callback = function(err, out) {
104 | if (err) {
105 | console.error(err);
106 | console.error(err.stack);
107 | process.exit(1);
108 | }
109 | console.log('Written to', out);
110 | process.exit(0);
111 | };
112 | render(argv._[0], argv, callback);
113 | }
114 |
115 | if (!module.parent) {
116 | main();
117 | }
118 |
--------------------------------------------------------------------------------
/examples/assets/loading.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flowhub/the-graph/6f6823fcd1a0956532ec68357a84aee2740e2640/examples/assets/loading.gif
--------------------------------------------------------------------------------
/examples/demo-full.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Graph Editor
5 |
6 |
7 |
8 |
9 |
10 |
11 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
loading custom elements...
54 |
55 |
56 |
57 |
--------------------------------------------------------------------------------
/examples/demo-full.js:
--------------------------------------------------------------------------------
1 | const fbpGraph = require('fbp-graph');
2 | const React = require('react');
3 | const ReactDOM = require('react-dom');
4 | const TheGraph = require('../index.js');
5 |
6 | require('font-awesome/css/font-awesome.css');
7 | require('../themes/the-graph-dark.styl');
8 | require('../themes/the-graph-light.styl');
9 |
10 | // Context menu specification
11 | function deleteNode(graph, itemKey, item) {
12 | graph.removeNode(itemKey);
13 | }
14 | function deleteEdge(graph, itemKey, item) {
15 | graph.removeEdge(item.from.node, item.from.port, item.to.node, item.to.port);
16 | }
17 | const contextMenus = {
18 | main: null,
19 | selection: null,
20 | nodeInport: null,
21 | nodeOutport: null,
22 | graphInport: null,
23 | graphOutport: null,
24 | edge: {
25 | icon: 'long-arrow-right',
26 | s4: {
27 | icon: 'trash',
28 | iconLabel: 'delete',
29 | action: deleteEdge,
30 | },
31 | },
32 | node: {
33 | s4: {
34 | icon: 'trash',
35 | iconLabel: 'delete',
36 | action: deleteNode,
37 | },
38 | },
39 | group: {
40 | icon: 'th',
41 | s4: {
42 | icon: 'trash',
43 | iconLabel: 'ungroup',
44 | action(graph, itemKey, item) {
45 | graph.removeGroup(itemKey);
46 | },
47 | },
48 | },
49 | };
50 |
51 | const appState = {
52 | graph: new fbpGraph.Graph(),
53 | library: {},
54 | iconOverrides: {},
55 | theme: 'dark',
56 | editorViewX: 0,
57 | editorViewY: 0,
58 | editorScale: 1,
59 | };
60 |
61 | // Attach nav
62 | function fitGraphInView() {
63 | editor.triggerFit();
64 | }
65 |
66 | function panEditorTo() {
67 | }
68 |
69 | function renderNav() {
70 | const view = [
71 | appState.editorViewX, appState.editorViewY,
72 | window.innerWidth, window.innerHeight,
73 | ];
74 | const props = {
75 | height: 162,
76 | width: 216,
77 | graph: appState.graph,
78 | onTap: fitGraphInView,
79 | onPanTo: panEditorTo,
80 | viewrectangle: view,
81 | viewscale: appState.editorScale,
82 | };
83 |
84 | const element = React.createElement(TheGraph.nav.Component, props);
85 | ReactDOM.render(element, document.getElementById('nav'));
86 | }
87 | function editorPanChanged(x, y, scale) {
88 | appState.editorViewX = -x;
89 | appState.editorViewY = -y;
90 | appState.editorScale = scale;
91 | renderNav();
92 | }
93 |
94 | function renderApp() {
95 | const editor = document.getElementById('editor');
96 | editor.className = `the-graph-${appState.theme}`;
97 |
98 | const props = {
99 | width: window.innerWidth,
100 | height: window.innerWidth,
101 | graph: appState.graph,
102 | library: appState.library,
103 | menus: contextMenus,
104 | nodeIcons: appState.iconOverrides,
105 | onPanScale: editorPanChanged,
106 | };
107 |
108 | editor.width = props.width;
109 | editor.height = props.height;
110 | const element = React.createElement(TheGraph.App, props);
111 | ReactDOM.render(element, editor);
112 |
113 | renderNav();
114 | }
115 | renderApp(); // initial
116 |
117 | // Follow changes in window size
118 | window.addEventListener('resize', renderApp);
119 |
120 | // Toggle theme
121 | let theme = 'dark';
122 | document.getElementById('theme').addEventListener('click', () => {
123 | theme = (theme === 'dark' ? 'light' : 'dark');
124 | appState.theme = theme;
125 | renderApp();
126 | });
127 |
128 | // Autolayout button
129 | document.getElementById('autolayout').addEventListener('click', () => {
130 | // TODO: support via React props
131 | editor.triggerAutolayout();
132 | });
133 |
134 | // Focus a node
135 | document.getElementById('focus').addEventListener('click', () => {
136 | // TODO: support via React props
137 | const { nodes } = appState.graph;
138 | const randomNode = nodes[Math.floor(Math.random() * nodes.length)];
139 | editor.focusNode(randomNode);
140 | });
141 |
142 | // Simulate node icon updates
143 | const iconKeys = Object.keys(TheGraph.FONT_AWESOME);
144 | window.setInterval(() => {
145 | const { nodes } = appState.graph;
146 | if (nodes.length > 0) {
147 | const randomNodeId = nodes[Math.floor(Math.random() * nodes.length)].id;
148 | const randomIcon = iconKeys[Math.floor(Math.random() * iconKeys.length)];
149 | appState.iconOverrides[randomNodeId] = randomIcon;
150 | renderApp();
151 | }
152 | }, 1000);
153 |
154 | // Simulate un/triggering errors
155 | let errorNodeId = null;
156 | const makeRandomError = function () {
157 | if (errorNodeId) {
158 | editor.removeErrorNode(errorNodeId);
159 | }
160 | const { nodes } = appState.graph;
161 | if (nodes.length > 0) {
162 | errorNodeId = nodes[Math.floor(Math.random() * nodes.length)].id;
163 | editor.addErrorNode(errorNodeId);
164 | editor.updateErrorNodes();
165 | }
166 | };
167 | // window.setInterval(makeRandomError, 3551); // TODO: support error nodes via React props
168 | // makeRandomError();
169 |
170 | // Load initial graph
171 | const loadingMessage = document.getElementById('loading-message');
172 | window.loadGraph = function (json) {
173 | // Load graph
174 | loadingMessage.innerHTML = 'loading graph data...';
175 |
176 | const graphData = json.data ? JSON.parse(json.data.files['noflo.json'].content) : json;
177 |
178 | fbpGraph.graph.loadJSON(JSON.stringify(graphData), (err, graph) => {
179 | if (err) {
180 | loadingMessage.innerHTML = `error loading graph: ${err.toString()}`;
181 | return;
182 | }
183 | // Remove loading message
184 | const loading = document.getElementById('loading');
185 | loading.parentNode.removeChild(loading);
186 | // Synthesize component library from graph
187 | appState.library = TheGraph.library.libraryFromGraph(graph);
188 | // Set loaded graph
189 | appState.graph = graph;
190 | appState.graph.on('endTransaction', renderApp); // graph changed
191 | renderApp();
192 |
193 | console.log('loaded', graph);
194 | });
195 | };
196 | require('./assets/photobooth.json.js');
197 |
--------------------------------------------------------------------------------
/examples/demo-simple.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Graph Editor Demo
5 |
6 |
7 |
8 |
9 |
10 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
loading custom elements...
47 |
48 |
49 |
50 |
--------------------------------------------------------------------------------
/examples/demo-simple.js:
--------------------------------------------------------------------------------
1 | const fbpGraph = require('fbp-graph');
2 | const React = require('react');
3 | const ReactDOM = require('react-dom');
4 | const TheGraph = require('../index.js');
5 |
6 | require('font-awesome/css/font-awesome.css');
7 | require('../themes/the-graph-dark.styl');
8 | require('../themes/the-graph-light.styl');
9 |
10 | // Remove loading message
11 | document.body.removeChild(document.getElementById('loading'));
12 |
13 | // The graph editor
14 | const editor = document.getElementById('editor');
15 |
16 | // Component library
17 | const library = {
18 | basic: {
19 | name: 'basic',
20 | description: 'basic demo component',
21 | icon: 'eye',
22 | inports: [
23 | { name: 'in0', type: 'all' },
24 | { name: 'in1', type: 'all' },
25 | { name: 'in2', type: 'all' },
26 | ],
27 | outports: [
28 | { name: 'out', type: 'all' },
29 | ],
30 | },
31 | tall: {
32 | name: 'tall',
33 | description: 'tall demo component',
34 | icon: 'cog',
35 | inports: [
36 | { name: 'in0', type: 'all' },
37 | { name: 'in1', type: 'all' },
38 | { name: 'in2', type: 'all' },
39 | { name: 'in3', type: 'all' },
40 | { name: 'in4', type: 'all' },
41 | { name: 'in5', type: 'all' },
42 | { name: 'in6', type: 'all' },
43 | { name: 'in7', type: 'all' },
44 | { name: 'in8', type: 'all' },
45 | { name: 'in9', type: 'all' },
46 | { name: 'in10', type: 'all' },
47 | { name: 'in11', type: 'all' },
48 | { name: 'in12', type: 'all' },
49 | ],
50 | outports: [
51 | { name: 'out0', type: 'all' },
52 | ],
53 | },
54 | };
55 |
56 | // Load empty graph
57 | let graph = new fbpGraph.Graph();
58 |
59 | function renderEditor() {
60 | const props = {
61 | readonly: false,
62 | height: window.innerHeight,
63 | width: window.innerWidth,
64 | graph,
65 | library,
66 | };
67 | // console.log('render', props);
68 | const editor = document.getElementById('editor');
69 | editor.width = props.width;
70 | editor.height = props.height;
71 | const element = React.createElement(TheGraph.App, props);
72 | ReactDOM.render(element, editor);
73 | }
74 | graph.on('endTransaction', renderEditor); // graph changed
75 | window.addEventListener('resize', renderEditor);
76 |
77 | // Add node button
78 | const addnode = function () {
79 | const id = Math.round(Math.random() * 100000).toString(36);
80 | const component = Math.random() > 0.5 ? 'basic' : 'tall';
81 | const metadata = {
82 | label: component,
83 | x: Math.round(Math.random() * 800),
84 | y: Math.round(Math.random() * 600),
85 | };
86 | const newNode = graph.addNode(id, component, metadata);
87 | return newNode;
88 | };
89 | document.getElementById('addnode').addEventListener('click', addnode);
90 |
91 | // Add edge button
92 | const addedge = function (outNodeID) {
93 | const { nodes } = graph;
94 | const len = nodes.length;
95 | if (len < 1) { return; }
96 | const node1 = outNodeID || nodes[Math.floor(Math.random() * len)].id;
97 | const node2 = nodes[Math.floor(Math.random() * len)].id;
98 | const port1 = `out${Math.floor(Math.random() * 3)}`;
99 | const port2 = `in${Math.floor(Math.random() * 12)}`;
100 | const meta = { route: Math.floor(Math.random() * 10) };
101 | const newEdge = graph.addEdge(node1, port1, node2, port2, meta);
102 | return newEdge;
103 | };
104 | document.getElementById('addedge').addEventListener('click', (event) => { addedge(); });
105 |
106 | // Random graph button
107 | document.getElementById('random').addEventListener('click', () => {
108 | graph.startTransaction('randomgraph');
109 | for (let i = 0; i < 20; i++) {
110 | const node = addnode();
111 | addedge(node.id);
112 | addedge(node.id);
113 | }
114 | graph.endTransaction('randomgraph');
115 | });
116 |
117 | // Get graph button
118 | document.getElementById('get').addEventListener('click', () => {
119 | const graphJSON = JSON.stringify(graph.toJSON(), null, 2);
120 | alert(graphJSON);
121 | // you can use the var graphJSON to save the graph definition in a file/database
122 | });
123 |
124 | // Clear button
125 | document.getElementById('clear').addEventListener('click', () => {
126 | graph = new fbpGraph.Graph();
127 | renderEditor();
128 | });
129 |
--------------------------------------------------------------------------------
/examples/demo-thumbnail.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | the-graph-thumb example
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/examples/demo-thumbnail.js:
--------------------------------------------------------------------------------
1 | const fbpGraph = require('fbp-graph');
2 | const TheGraph = require('../index.js');
3 |
4 | window.loadGraph = function (json) {
5 | // Load graph
6 | const graphData = json.data.files['noflo.json'].content;
7 | fbpGraph.graph.loadJSON(graphData, (err, graph) => {
8 | if (err) {
9 | throw err;
10 | }
11 |
12 | // Render the numbnail
13 | const thumb = document.getElementById('thumb');
14 | const properties = TheGraph.thumb.styleFromTheme('dark');
15 | properties.width = thumb.width;
16 | properties.height = thumb.height;
17 | properties.nodeSize = 60;
18 | properties.lineWidth = 1;
19 | const context = thumb.getContext('2d');
20 | const info = TheGraph.thumb.render(context, graph, properties);
21 | });
22 | };
23 | const body = document.querySelector('body');
24 | const script = document.createElement('script');
25 | script.type = 'application/javascript';
26 | // Clock
27 | script.src = 'https://api.github.com/gists/7135158?callback=loadGraph';
28 | // Gesture object building (lots of ports!)
29 | // script.src = 'https://api.github.com/gists/7022120?callback=loadGraph';
30 | // Gesture data gathering (big graph)
31 | // script.src = 'https://api.github.com/gists/7022262?callback=loadGraph';
32 | // Edge algo test
33 | // script.src = 'https://api.github.com/gists/6890344?callback=loadGraph';
34 | body.appendChild(script);
35 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | // Module object
2 | const TheGraph = {};
3 |
4 | // Bundle and expose fbp-graph as public API
5 | TheGraph.fbpGraph = require('fbp-graph');
6 |
7 | // Pull in Ease from NPM, react.animate needs it as a global
8 | TheGraph.Ease = require('ease-component');
9 |
10 | if (typeof window !== 'undefined' && typeof window.Ease === 'undefined') {
11 | window.Ease = TheGraph.Ease;
12 | }
13 |
14 | const defaultNodeSize = 72;
15 | const defaultNodeRadius = 8;
16 |
17 | const moduleVars = {
18 | // Context menus
19 | contextPortSize: 36,
20 | // Zoom breakpoints
21 | zbpBig: 1.2,
22 | zbpNormal: 0.4,
23 | zbpSmall: 0.01,
24 | config: {
25 | nodeSize: defaultNodeSize,
26 | nodeWidth: defaultNodeSize,
27 | nodeRadius: defaultNodeRadius,
28 | nodeHeight: defaultNodeSize,
29 | autoSizeNode: true,
30 | maxPortCount: 9,
31 | nodeHeightIncrement: 12,
32 | focusAnimationDuration: 1500,
33 | },
34 | };
35 | Object.keys(moduleVars).forEach((key) => {
36 | TheGraph[key] = moduleVars[key];
37 | });
38 |
39 | if (typeof window !== 'undefined') {
40 | // rAF shim
41 | window.requestAnimationFrame = window.requestAnimationFrame
42 | || window.webkitRequestAnimationFrame
43 | || window.mozRequestAnimationFrame
44 | || window.msRequestAnimationFrame;
45 | }
46 |
47 | // HACK, goes away when everything is CommonJS compatible
48 | const g = { TheGraph };
49 |
50 | TheGraph.factories = require('./the-graph/factories.js');
51 | TheGraph.merge = require('./the-graph/merge.js');
52 |
53 | require('./the-graph/the-graph-app.js').register(g);
54 | require('./the-graph/the-graph-graph.js').register(g);
55 | require('./the-graph/the-graph-node.js').register(g);
56 | require('./the-graph/the-graph-node-menu.js').register(g);
57 | require('./the-graph/the-graph-node-menu-port.js').register(g);
58 | require('./the-graph/the-graph-node-menu-ports.js').register(g);
59 | require('./the-graph/the-graph-port.js').register(g);
60 | require('./the-graph/the-graph-edge.js').register(g);
61 | require('./the-graph/the-graph-iip.js').register(g);
62 | require('./the-graph/the-graph-group.js').register(g);
63 |
64 | TheGraph.menu = require('./the-graph/the-graph-menu.js');
65 | // compat
66 | TheGraph.Menu = TheGraph.menu.Menu;
67 | TheGraph.factories.menu = TheGraph.menu.factories;
68 | TheGraph.config.menu = TheGraph.menu.config;
69 | TheGraph.config.menu.iconRect.rx = TheGraph.config.nodeRadius;
70 | TheGraph.config.menu.iconRect.ry = TheGraph.config.nodeRadius;
71 |
72 | TheGraph.modalbg = require('./the-graph/the-graph-modalbg.js');
73 | // compat
74 | TheGraph.ModalBG = TheGraph.modalbg.ModalBG;
75 | TheGraph.config.ModalBG = TheGraph.config.factories;
76 | TheGraph.factories.ModalBG = TheGraph.modalbg.factories;
77 |
78 | TheGraph.FONT_AWESOME = require('./the-graph/font-awesome-unicode-map.js');
79 |
80 | const geometryutils = require('./the-graph/geometryutils');
81 | // compat
82 | TheGraph.findMinMax = geometryutils.findMinMax;
83 | TheGraph.findNodeFit = geometryutils.findNodeFit;
84 | TheGraph.findFit = geometryutils.findFit;
85 |
86 | TheGraph.tooltip = require('./the-graph/the-graph-tooltip.js');
87 | // compat
88 | TheGraph.Tooltip = TheGraph.tooltip.Tooltip;
89 | TheGraph.config.tooltip = TheGraph.tooltip.config;
90 | TheGraph.factories.tooltip = TheGraph.tooltip.factories;
91 |
92 | TheGraph.mixins = require('./the-graph/mixins.js');
93 | TheGraph.arcs = require('./the-graph/arcs.js');
94 |
95 | TheGraph.TextBG = require('./the-graph/TextBG.js');
96 | TheGraph.SVGImage = require('./the-graph/SVGImage.js');
97 |
98 | TheGraph.thumb = require('./the-graph-thumb/the-graph-thumb.js');
99 |
100 | TheGraph.nav = require('./the-graph-nav/the-graph-nav.js');
101 |
102 | TheGraph.autolayout = require('./the-graph/the-graph-autolayout.js');
103 | TheGraph.library = require('./the-graph/the-graph-library.js');
104 |
105 | TheGraph.clipboard = require('./the-graph-editor/clipboard.js');
106 |
107 | TheGraph.render = require('./the-graph/render.js');
108 |
109 | TheGraph.render.register(g);
110 |
111 | module.exports = TheGraph;
112 |
--------------------------------------------------------------------------------
/jest-setup.js:
--------------------------------------------------------------------------------
1 | const Enzyme = require('enzyme');
2 | const Adapter = require('enzyme-adapter-react-15');
3 |
4 | Enzyme.configure({ adapter: new Adapter() });
5 | document.documentElement.ontouchstart = () => {};
6 | window.ontouchstart = () => {};
7 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "the-graph",
3 | "version": "0.13.1",
4 | "description": "flow-based programming graph editing",
5 | "author": "Forrest Oliphant, the Grid",
6 | "license": "MIT",
7 | "main": "index.js",
8 | "bin": {
9 | "the-graph-render": "./bin/the-graph-render"
10 | },
11 | "dependencies": {
12 | "@pleasetrythisathome/react.animate": "0.0.4",
13 | "create-react-class": "^15.6.2",
14 | "ease-component": "^1.0.0",
15 | "fbp-graph": "^0.7.0",
16 | "font-awesome": "^4.7.0",
17 | "hammerjs": "^2.0.8",
18 | "klayjs-noflo": "^0.3.1",
19 | "tv4": "^1.3.0",
20 | "yargs": "^16.1.1"
21 | },
22 | "devDependencies": {
23 | "bluebird": "^3.5.1",
24 | "chai": "^4.1.2",
25 | "css-loader": "^5.0.1",
26 | "enzyme": "^3.2.0",
27 | "enzyme-adapter-react-15": "^1.0.5",
28 | "eslint": "^7.13.0",
29 | "eslint-config-airbnb": "^18.2.1",
30 | "eslint-plugin-import": "^2.22.1",
31 | "eslint-plugin-jsx-a11y": "^6.4.1",
32 | "eslint-plugin-react": "^7.21.5",
33 | "events": "^3.2.0",
34 | "file-loader": "^6.2.0",
35 | "html-webpack-plugin": "^5.0.0",
36 | "http-server": "^0.12.3",
37 | "jest": "^21.2.1",
38 | "jest-enzyme": "^4.0.1",
39 | "jsjob": "^0.10.13",
40 | "mocha": "^8.2.1",
41 | "noflo-canvas": "0.4.2",
42 | "react": "^15.6.2",
43 | "react-dom": "^15.6.2",
44 | "react-test-renderer": "^15.6.2",
45 | "style-loader": "^2.0.0",
46 | "stylus": "~0.54.5",
47 | "stylus-loader": "^5.0.0",
48 | "webpack": "^5.5.1",
49 | "webpack-cli": "^4.2.0"
50 | },
51 | "scripts": {
52 | "lint": "eslint --fix index.js render.jsjob.js the-graph-thumb the-graph-nav the-graph-editor",
53 | "fontawesome": "node scripts/build-font-awesome-javascript.js",
54 | "stylus": "stylus -c -o dist -m themes/*.styl",
55 | "prebuild": "npm run fontawesome",
56 | "build": "webpack",
57 | "postbuild": "npm run stylus",
58 | "pretest": "npm run lint && npm run build",
59 | "test": "jest",
60 | "start": "http-server dist/ -p 3000 -s -c-1"
61 | },
62 | "repository": {
63 | "type": "git",
64 | "url": "git://github.com/flowhub/the-graph.git"
65 | },
66 | "keywords": [
67 | "graph"
68 | ],
69 | "peerDependencies": {
70 | "react": "<16.0.0",
71 | "react-dom": "<16.0.0"
72 | },
73 | "jest": {
74 | "coveragePathIgnorePatterns": [
75 | "/node_modules/",
76 | "/dist/",
77 | "/jest-setup.js"
78 | ],
79 | "setupFiles": [
80 | "/jest-setup.js"
81 | ],
82 | "setupTestFrameworkScriptFile": "jest-enzyme"
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/render.jsjob.js:
--------------------------------------------------------------------------------
1 | // JsJob entrypoint for rendering a FBP graph to an SVG/JPEG/PNG
2 |
3 | const TheGraph = require('./index');
4 |
5 | require('./themes/the-graph-dark.styl');
6 | require('./themes/the-graph-light.styl');
7 |
8 | function waitForStyleLoad(callback) {
9 | // FIXME: check properly, https://gist.github.com/cvan/8a188df72a95a35888b70e5fda80450d
10 | setTimeout(callback, 500);
11 | }
12 |
13 | window.jsJobRun = function jsJobRun(inputdata, options, callback) {
14 | let loader = TheGraph.fbpGraph.graph.loadJSON;
15 | let graphData = inputdata;
16 | if (inputdata.fbp) {
17 | graphData = inputdata.fbp;
18 | loader = TheGraph.fbpGraph.graph.loadFBP;
19 | }
20 |
21 | loader(graphData, (err, graph) => {
22 | if (err) {
23 | callback(err);
24 | return;
25 | }
26 | console.log('loaded graph');
27 |
28 | waitForStyleLoad(() => {
29 | let node;
30 | try {
31 | node = TheGraph.render.graphToDOM(graph, options);
32 | } catch (e) {
33 | callback(e);
34 | return;
35 | }
36 | TheGraph.render.exportImage(node, options, callback);
37 | });
38 | });
39 | };
40 |
--------------------------------------------------------------------------------
/scripts/build-font-awesome-javascript.js:
--------------------------------------------------------------------------------
1 | /**
2 | * This generates ../the-graph/font-awesome-unicode-map.js for use in our SVG
3 | */
4 |
5 | const fs = require('fs');
6 |
7 | function generateFile(err, data) {
8 | if (err) {
9 | throw err;
10 | }
11 |
12 | const linePattern = /@fa-var-[^;]*/g;
13 | const lines = data.match(linePattern);
14 | const icons = {};
15 | lines.forEach((line) => {
16 | const namePattern = /@fa-var-(.*): "\\(.*)"/;
17 | const match = namePattern.exec(line);
18 | if (match) {
19 | const key = match[1];
20 | let u = `%u${match[2]}`;
21 | u = unescape(u);
22 | icons[key] = u;
23 | }
24 | });
25 |
26 | const output = `// This file is generated via \`npm run fontawesome\`\nmodule.exports = ${JSON.stringify(icons, null, 2).replace(/"/g, '\'')};`;
27 |
28 | fs.writeFile(`${__dirname}/../the-graph/font-awesome-unicode-map.js`, output, (writeErr) => {
29 | if (writeErr) {
30 | throw writeErr;
31 | }
32 | console.log(`Font Awesome icons map saved with ${Object.keys(icons).length} icons and aliases.`);
33 | });
34 | }
35 |
36 | fs.readFile(`${__dirname}/../node_modules/font-awesome/less/variables.less`, 'utf8', generateFile);
37 |
--------------------------------------------------------------------------------
/spec/render-cli.js:
--------------------------------------------------------------------------------
1 | const chai = require('chai');
2 | const bluebird = require('bluebird');
3 |
4 | const child_process = require('child_process');
5 | const fs = require('fs');
6 | const path = require('path');
7 |
8 | // cannot be bluebird.promisified, because returns data
9 | function execFile(prog, args, options) {
10 | return new Promise((resolve, reject) =>
11 | child_process.execFile(prog, args, options, (err, stdout, stderr) => {
12 | if (err) reject(err)
13 | else resolve({ stdout: stdout, stderr: stderr })
14 | })
15 | )
16 | }
17 |
18 | function readFile(fp) {
19 | return new Promise((resolve, reject) =>
20 | fs.readFile(fp, (err, data) => {
21 | if (err) reject(err)
22 | else resolve(data)
23 | })
24 | )
25 | }
26 |
27 | //bluebird.promisifyAll(fs.readFile)(options.output)
28 |
29 | function runRenderCli(inputGraph, options) {
30 | const prog = path.join(__dirname, '../bin/the-graph-render');
31 |
32 | //const graphPath = path.join(__dirname, 'temp/graph.json');
33 | options.output = path.join(__dirname, 'temp/rendered.tmp');
34 |
35 | var args = [ inputGraph ];
36 | Object.keys(options).forEach((key) => {
37 | args.push('--'+key);
38 | args.push(options[key]);
39 | })
40 | return bluebird.resolve(null).then(() => {
41 | //console.log('running', [prog].concat(args).join(' '));
42 | return execFile(prog, args)
43 | }).then((out) => {
44 | chai.expect(out.stdout).to.include('Written to');
45 | chai.expect(out.stdout).to.include(options.output);
46 | return readFile(options.output)
47 | }).then((data) => {
48 | return data
49 | })
50 | }
51 |
52 | function fixture(name) {
53 | return path.join(__dirname, 'fixtures/'+name);
54 | }
55 |
56 | const pb = fixture('photobooth.json');
57 | const jpegMagic = Buffer.from([0xff, 0xd8]);
58 | const pngMagic = Buffer.from([0x89, 0x50]);
59 | const renderTimeout = 10*1000;
60 |
61 | describe('the-graph-render', () => {
62 |
63 | before(() => {
64 | const tempDir = path.join(__dirname,'temp');
65 | bluebird.promisify(fs.access)(tempDir).catch((stats) => {
66 | return bluebird.promisify(fs.mkdir)(tempDir)
67 | });
68 | })
69 |
70 | describe('with no options', () => {
71 | it('should output PNG file', () => {
72 | return runRenderCli(pb, {}).then((out) => {
73 | const magic = out.slice(0, 2).toString('hex');
74 | chai.expect(magic).to.equal(pngMagic.toString('hex'))
75 | })
76 | }).timeout(renderTimeout)
77 | })
78 |
79 | describe('requesting JPEG', () => {
80 | it('should output JPEG file', () => {
81 | return runRenderCli(pb, { format: 'jpeg', quality: 1.0 }).then((out) => {
82 | const magic = out.slice(0, 2).toString('hex');
83 | chai.expect(magic).to.equal(jpegMagic.toString('hex'))
84 | })
85 | }).timeout(renderTimeout)
86 | })
87 |
88 | describe('requesting SVG', () => {
89 | it.skip('should output SVG file', () => {
90 | return runRenderCli(pb, { format: 'svg' }).then((out) => {
91 | const contents = out.toString('utf8');
92 | chai.expect(contents).to.include('