@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.
@@ -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