@luispm/zflow-graph 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +287 -0
- package/SECURITY.md +67 -0
- package/dist/adapters/yjs.esm.js +238 -0
- package/dist/adapters/yjs.esm.min.js +2 -0
- package/dist/adapters/yjs.umd.js +246 -0
- package/dist/webgl-renderer.esm.js +376 -0
- package/dist/webgl-renderer.esm.min.js +2 -0
- package/dist/webgl-renderer.umd.js +384 -0
- package/dist/zflow.esm.js +4365 -0
- package/dist/zflow.esm.js.map +1 -0
- package/dist/zflow.esm.min.js +2 -0
- package/dist/zflow.umd.js +4375 -0
- package/dist/zflow.umd.js.map +1 -0
- package/dist/zflow.umd.min.js +2 -0
- package/dist/zflow.wasm +0 -0
- package/docs/01-getting-started.md +210 -0
- package/docs/02-runtime.md +257 -0
- package/docs/03-kinds.md +282 -0
- package/docs/04-performance.md +218 -0
- package/docs/05-plugins.md +235 -0
- package/docs/06-multiplayer.md +180 -0
- package/docs/07-recipes.md +340 -0
- package/docs/08-api.md +436 -0
- package/docs/README.md +61 -0
- package/package.json +100 -0
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
# 7 · Recipes
|
|
2
|
+
|
|
3
|
+
Concrete, paste-and-run examples for common scenarios.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Recipe 1: A workflow tool with HTTP and Slack
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
flow.registerKind({
|
|
11
|
+
name: 'http-get',
|
|
12
|
+
color: '#5b8def', badge: 'H', nin: 1, nout: 2,
|
|
13
|
+
portIn: ['url'], portOut: ['body', 'error'],
|
|
14
|
+
retry: { n: 3, delay: 500 },
|
|
15
|
+
execute: async (ctx, ins) => {
|
|
16
|
+
try {
|
|
17
|
+
const r = await fetch(ins.url, { signal: ctx.signal });
|
|
18
|
+
return { body: await r.text() };
|
|
19
|
+
} catch (e) {
|
|
20
|
+
return { error: e.message };
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
flow.registerKind({
|
|
26
|
+
name: 'slack-send',
|
|
27
|
+
color: '#5bd17a', badge: 'S', nin: 1, nout: 0,
|
|
28
|
+
portIn: ['message'],
|
|
29
|
+
execute: async (ctx, ins) => {
|
|
30
|
+
await fetch('https://hooks.slack.com/your-webhook', {
|
|
31
|
+
method: 'POST',
|
|
32
|
+
body: JSON.stringify({ text: ins.message }),
|
|
33
|
+
signal: ctx.signal,
|
|
34
|
+
});
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
flow.registerKind({
|
|
39
|
+
name: 'filter-contains',
|
|
40
|
+
color: '#e8b04b', badge: '∋', nin: 1, nout: 2,
|
|
41
|
+
portIn: ['text'], portOut: ['matched', 'no'],
|
|
42
|
+
execute: (ctx, ins) => {
|
|
43
|
+
const needle = ctx.params.needle || '';
|
|
44
|
+
return ins.text.includes(needle) ? { matched: ins.text } : { no: ins.text };
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const url = flow.addNode({ kind: 'http-get', x: 0, y: 0, title: 'fetch logs' });
|
|
49
|
+
const filter = flow.addNode({ kind: 'filter-contains', x: 240, y: 0, title: 'find errors' });
|
|
50
|
+
const send = flow.addNode({ kind: 'slack-send', x: 480, y: 0, title: 'notify team' });
|
|
51
|
+
|
|
52
|
+
flow.setNodeParams(filter, { needle: 'ERROR' });
|
|
53
|
+
flow.setNodeInput(url, { url: 'https://api/logs' });
|
|
54
|
+
|
|
55
|
+
flow.addEdge({ from: url, fp: 0, to: filter }); // body → filter
|
|
56
|
+
flow.addEdge({ from: filter, fp: 0, to: send }); // matched → slack
|
|
57
|
+
|
|
58
|
+
flow.startLoop(60_000); // poll every minute
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Recipe 2: Live data dashboard
|
|
64
|
+
|
|
65
|
+
```js
|
|
66
|
+
flow.registerKind({
|
|
67
|
+
name: 'poll',
|
|
68
|
+
nin: 0, nout: 1,
|
|
69
|
+
execute: async (ctx) => {
|
|
70
|
+
const r = await fetch(ctx.params.url, { signal: ctx.signal });
|
|
71
|
+
return r.json();
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
flow.registerKind({
|
|
76
|
+
name: 'metric-card',
|
|
77
|
+
html: true,
|
|
78
|
+
template: '<div style="padding:12px;color:#5be0d0;font-size:24px;font-weight:700;text-align:center;" class="value">—</div>',
|
|
79
|
+
w: 200, h: 80, nin: 1, nout: 0,
|
|
80
|
+
execute: (ctx, ins) => {
|
|
81
|
+
const el = flow._htmlOverlays.get(ctx.nodeId);
|
|
82
|
+
el.querySelector('.value').textContent = ins.in0 ?? ins[0];
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const poll = flow.addNode({ kind: 'poll', x: -200, y: 0, title: '/api/users/count' });
|
|
87
|
+
const card = flow.addNode({ kind: 'metric-card', x: 100, y: 0 });
|
|
88
|
+
|
|
89
|
+
flow.setNodeParams(poll, { url: '/api/users/count' });
|
|
90
|
+
flow.addEdge({ from: poll, to: card });
|
|
91
|
+
|
|
92
|
+
flow.startLoop(2000); // refresh every 2s
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## Recipe 3: Image-processing pipeline (ComfyUI vibe)
|
|
98
|
+
|
|
99
|
+
```js
|
|
100
|
+
flow.registerKind({
|
|
101
|
+
name: 'load-image',
|
|
102
|
+
nin: 0, nout: 1, portOut: ['image'],
|
|
103
|
+
execute: async (ctx) => {
|
|
104
|
+
const img = new Image();
|
|
105
|
+
img.crossOrigin = 'anonymous';
|
|
106
|
+
img.src = ctx.params.url;
|
|
107
|
+
await img.decode();
|
|
108
|
+
const c = document.createElement('canvas');
|
|
109
|
+
c.width = img.width; c.height = img.height;
|
|
110
|
+
c.getContext('2d').drawImage(img, 0, 0);
|
|
111
|
+
return { image: c };
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
flow.registerKind({
|
|
116
|
+
name: 'grayscale',
|
|
117
|
+
nin: 1, nout: 1, portIn: ['image'], portOut: ['image'],
|
|
118
|
+
execute: (ctx, ins) => {
|
|
119
|
+
const src = ins.image;
|
|
120
|
+
const out = document.createElement('canvas');
|
|
121
|
+
out.width = src.width; out.height = src.height;
|
|
122
|
+
const sctx = src.getContext('2d');
|
|
123
|
+
const data = sctx.getImageData(0, 0, src.width, src.height);
|
|
124
|
+
for (let i = 0; i < data.data.length; i += 4) {
|
|
125
|
+
const g = data.data[i] * 0.3 + data.data[i+1] * 0.59 + data.data[i+2] * 0.11;
|
|
126
|
+
data.data[i] = data.data[i+1] = data.data[i+2] = g;
|
|
127
|
+
}
|
|
128
|
+
out.getContext('2d').putImageData(data, 0, 0);
|
|
129
|
+
return { image: out };
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
flow.registerKind({
|
|
134
|
+
name: 'show-image',
|
|
135
|
+
html: true,
|
|
136
|
+
template: '<img class="out" style="width:100%;height:100%;object-fit:contain;">',
|
|
137
|
+
w: 240, h: 240, nin: 1, nout: 0, portIn: ['image'],
|
|
138
|
+
execute: (ctx, ins) => {
|
|
139
|
+
const el = flow._htmlOverlays.get(ctx.nodeId);
|
|
140
|
+
const url = ins.image.toDataURL();
|
|
141
|
+
el.querySelector('.out').src = url;
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const load = flow.addNode({ kind: 'load-image' });
|
|
146
|
+
const gray = flow.addNode({ kind: 'grayscale' });
|
|
147
|
+
const show = flow.addNode({ kind: 'show-image' });
|
|
148
|
+
|
|
149
|
+
flow.setNodeParams(load, { url: 'https://picsum.photos/400' });
|
|
150
|
+
flow.addEdge({ from: load, to: gray });
|
|
151
|
+
flow.addEdge({ from: gray, to: show });
|
|
152
|
+
|
|
153
|
+
await flow.run();
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## Recipe 4: A sidebar with draggable nodes (palette)
|
|
159
|
+
|
|
160
|
+
```html
|
|
161
|
+
<div id="palette" style="position:fixed;top:14px;left:14px;display:flex;flex-direction:column;gap:6px;">
|
|
162
|
+
<div class="pal" data-kind="http-get">HTTP GET</div>
|
|
163
|
+
<div class="pal" data-kind="slack-send">Slack</div>
|
|
164
|
+
<div class="pal" data-kind="filter-contains">Filter</div>
|
|
165
|
+
</div>
|
|
166
|
+
<style>
|
|
167
|
+
.pal { padding: 8px 12px; background: rgba(91,141,239,0.15); color: #5b8def;
|
|
168
|
+
border-radius: 5px; cursor: grab; font: 600 12px sans-serif; user-select: none; }
|
|
169
|
+
</style>
|
|
170
|
+
<script type="module">
|
|
171
|
+
// ... create flow ...
|
|
172
|
+
|
|
173
|
+
document.querySelectorAll('.pal').forEach((el) => {
|
|
174
|
+
flow.makeDraggable(el, {
|
|
175
|
+
kind: el.dataset.kind,
|
|
176
|
+
title: el.textContent,
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
</script>
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Drag a chip from the palette into the canvas — a node of that kind is created at the drop position.
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## Recipe 5: Read-only mode for sharing
|
|
187
|
+
|
|
188
|
+
```js
|
|
189
|
+
const url = new URL(location.href);
|
|
190
|
+
if (url.searchParams.has('view')) {
|
|
191
|
+
flow.setReadOnly(true);
|
|
192
|
+
// Hide the toolbar / palette
|
|
193
|
+
document.getElementById('toolbar').style.display = 'none';
|
|
194
|
+
}
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
In read-only mode:
|
|
198
|
+
- `addNode`, `addEdge`, `deleteSelection` are no-ops (return -1)
|
|
199
|
+
- Dragging and resizing are disabled
|
|
200
|
+
- Selection and pan/zoom still work — users can explore but not mutate
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
## Recipe 6: Snapshot diff between versions
|
|
205
|
+
|
|
206
|
+
```js
|
|
207
|
+
function diff(beforeJSON, afterJSON) {
|
|
208
|
+
const before = new Map(beforeJSON.nodes.map((n, i) => [i, n]));
|
|
209
|
+
const after = new Map(afterJSON.nodes.map((n, i) => [i, n]));
|
|
210
|
+
const added = [], removed = [], moved = [];
|
|
211
|
+
for (const [i, n] of after) {
|
|
212
|
+
if (!before.has(i)) added.push(n);
|
|
213
|
+
else if (before.get(i).x !== n.x || before.get(i).y !== n.y) moved.push(n);
|
|
214
|
+
}
|
|
215
|
+
for (const [i, n] of before) if (!after.has(i)) removed.push(n);
|
|
216
|
+
return { added, removed, moved };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const snapA = flow.toJSON();
|
|
220
|
+
// ... user makes changes ...
|
|
221
|
+
const snapB = flow.toJSON();
|
|
222
|
+
const delta = diff(snapA, snapB);
|
|
223
|
+
console.log(`+${delta.added.length} -${delta.removed.length} ~${delta.moved.length}`);
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
---
|
|
227
|
+
|
|
228
|
+
## Recipe 7: Custom right-click menu
|
|
229
|
+
|
|
230
|
+
```js
|
|
231
|
+
flow.canvas.addEventListener('contextmenu', (e) => {
|
|
232
|
+
e.preventDefault();
|
|
233
|
+
const wp = flow._s2w(e.clientX, e.clientY);
|
|
234
|
+
const nid = flow.w.hitTestNode(wp.x, wp.y);
|
|
235
|
+
if (nid !== -1) {
|
|
236
|
+
flow._showMenu(e.clientX, e.clientY, [
|
|
237
|
+
{ label: 'Rename', run: () => flow.editNodeExpression(nid, 'title') },
|
|
238
|
+
{ label: 'Duplicate', run: () => { flow.setSelected(nid, true); flow.duplicateSelection(); } },
|
|
239
|
+
{ label: 'Pin', run: () => flow.lockNode(nid, true) },
|
|
240
|
+
{ label: 'Run from', run: () => flow.runFrom(nid) },
|
|
241
|
+
{ label: 'Delete', run: () => { flow.setSelected(nid, true); flow.deleteSelection(); } },
|
|
242
|
+
]);
|
|
243
|
+
}
|
|
244
|
+
}, true); // capture phase to override the default menu
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
---
|
|
248
|
+
|
|
249
|
+
## Recipe 8: Build a chat with kinds (LLM-driven flow)
|
|
250
|
+
|
|
251
|
+
```js
|
|
252
|
+
flow.registerKind({
|
|
253
|
+
name: 'llm-prompt',
|
|
254
|
+
html: true,
|
|
255
|
+
template: '<textarea class="prompt" style="width:100%;height:80%;background:#0b0f17;color:white;border:0;padding:8px;font-family:inherit;font-size:13px;resize:none;"></textarea><button class="run" style="position:absolute;bottom:8px;right:8px;background:#5b8def;color:white;border:0;padding:5px 10px;border-radius:4px;">Run</button>',
|
|
256
|
+
w: 320, h: 180, nin: 0, nout: 1, portOut: ['response'],
|
|
257
|
+
execute: async (ctx) => {
|
|
258
|
+
const r = await fetch('https://api.anthropic.com/v1/messages', {
|
|
259
|
+
method: 'POST',
|
|
260
|
+
headers: { 'content-type': 'application/json', 'x-api-key': YOUR_KEY },
|
|
261
|
+
signal: ctx.signal,
|
|
262
|
+
body: JSON.stringify({
|
|
263
|
+
model: 'claude-haiku-4-5-20251001',
|
|
264
|
+
max_tokens: 1024,
|
|
265
|
+
messages: [{ role: 'user', content: ctx.params.prompt }],
|
|
266
|
+
}),
|
|
267
|
+
});
|
|
268
|
+
const data = await r.json();
|
|
269
|
+
return { response: data.content[0].text };
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
const llm = flow.addNode({ kind: 'llm-prompt' });
|
|
274
|
+
requestAnimationFrame(() => {
|
|
275
|
+
const el = flow._htmlOverlays.get(llm);
|
|
276
|
+
el.querySelector('.run').onclick = () => {
|
|
277
|
+
const prompt = el.querySelector('.prompt').value;
|
|
278
|
+
flow.setNodeParams(llm, { prompt });
|
|
279
|
+
flow.runFrom(llm);
|
|
280
|
+
};
|
|
281
|
+
});
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
(In production, route through your backend so the API key isn't in the browser.)
|
|
285
|
+
|
|
286
|
+
---
|
|
287
|
+
|
|
288
|
+
## Recipe 9: Export as image
|
|
289
|
+
|
|
290
|
+
```js
|
|
291
|
+
async function exportAsImage(format = 'png') {
|
|
292
|
+
if (format === 'svg') {
|
|
293
|
+
const svg = flow.exportSVG();
|
|
294
|
+
const blob = new Blob([svg], { type: 'image/svg+xml' });
|
|
295
|
+
return URL.createObjectURL(blob);
|
|
296
|
+
}
|
|
297
|
+
const blob = await flow.exportPNG();
|
|
298
|
+
return URL.createObjectURL(blob);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
document.getElementById('export').onclick = async () => {
|
|
302
|
+
const url = await exportAsImage('png');
|
|
303
|
+
const a = document.createElement('a');
|
|
304
|
+
a.href = url;
|
|
305
|
+
a.download = 'graph.png';
|
|
306
|
+
a.click();
|
|
307
|
+
};
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
---
|
|
311
|
+
|
|
312
|
+
## Recipe 10: Save to a backend, load on mount
|
|
313
|
+
|
|
314
|
+
```js
|
|
315
|
+
async function load() {
|
|
316
|
+
const r = await fetch('/api/graphs/current');
|
|
317
|
+
if (!r.ok) return;
|
|
318
|
+
flow.loadJSON(await r.json());
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
let saveTimer = null;
|
|
322
|
+
flow.on('change', () => {
|
|
323
|
+
clearTimeout(saveTimer);
|
|
324
|
+
saveTimer = setTimeout(async () => {
|
|
325
|
+
await fetch('/api/graphs/current', {
|
|
326
|
+
method: 'PUT',
|
|
327
|
+
headers: { 'content-type': 'application/json' },
|
|
328
|
+
body: JSON.stringify(flow.toJSON()),
|
|
329
|
+
});
|
|
330
|
+
}, 800); // debounce: only save 800ms after the last edit
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
await load();
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
---
|
|
337
|
+
|
|
338
|
+
## Next
|
|
339
|
+
|
|
340
|
+
→ [API Reference](./08-api.md) — every method, every event
|