quickgrid commited on
Commit
34c7fc5
·
verified ·
1 Parent(s): 4de99f4

Update index.html

Browse files
Files changed (1) hide show
  1. index.html +1332 -18
index.html CHANGED
@@ -1,19 +1,1333 @@
1
- <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  </html>
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <title>Node Image Processor</title>
7
+ <script src="https://cdn.jsdelivr.net/pyodide/v0.29.3/full/pyodide.js"></script>
8
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.css">
9
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/theme/material-darker.min.css">
10
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.js"></script>
11
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/python/python.min.js"></script>
12
+ <style>
13
+ *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
14
+ :root{
15
+ --bg:#0d1117;--surface:#161b22;--surface2:#1c2333;--surface3:#222d3d;
16
+ --border:#30363d;--accent:#ff6b6b;--accent2:#4ecdc4;--text:#e6edf3;
17
+ --muted:#8b949e;--success:#3fb950;--warning:#d29922;--danger:#f85149;
18
+ --sidebar-w:clamp(160px,14vw,230px);--toolbar-h:2.5rem;--radius:.5rem;
19
+ --font:.8125rem;--font-sm:.6875rem;--font-xs:.5625rem
20
+ }
21
+ html,body{width:100%;height:100%;overflow:hidden;font-family:'Segoe UI',system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);font-size:16px}
22
+ #loader{position:fixed;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.875rem;background:var(--bg);color:var(--text);z-index:9999;transition:opacity .5s}
23
+ #loader.done{opacity:0;pointer-events:none}
24
+ .spinner{width:2.25rem;height:2.25rem;border:3px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .7s linear infinite}
25
+ @keyframes spin{to{transform:rotate(360deg)}}
26
+ #ldMsg{font-size:var(--font);font-weight:700}
27
+ #ldSub{font-size:var(--font-sm);color:var(--muted);max-width:25rem;text-align:center;line-height:1.4}
28
+ #toast{position:fixed;bottom:1.5rem;left:50%;transform:translateX(-50%) translateY(4rem);padding:.5rem 1.375rem;border-radius:var(--radius);font-size:var(--font-sm);font-weight:700;z-index:10000;opacity:0;transition:transform .3s cubic-bezier(.4,0,.2,1),opacity .3s;pointer-events:none;white-space:nowrap}
29
+ #toast.show{transform:translateX(-50%) translateY(0);opacity:1}
30
+ #toast.ok{background:#0f2a1a;color:var(--success);border:1px solid #1a4028}
31
+ #toast.err{background:#2a0f0f;color:var(--danger);border:1px solid #401a1a}
32
+ #toast.inf{background:#0f1a2a;color:#58a6ff;border:1px solid #1a2840}
33
+ #app{display:flex;width:100%;height:100%}
34
+ /* SIDEBAR */
35
+ #sidebar{width:var(--sidebar-w);background:var(--surface);border-right:1px solid var(--border);display:flex;flex-direction:column;flex-shrink:0;transition:width .2s}
36
+ .sb-tabs{display:flex;border-bottom:1px solid var(--border);height:var(--toolbar-h);flex-shrink:0}
37
+ .sb-tab{flex:1;display:flex;align-items:center;justify-content:center;font-size:var(--font-xs);font-weight:800;cursor:pointer;color:var(--muted);border-bottom:2px solid transparent;transition:all .15s;text-transform:uppercase;letter-spacing:.5px;padding:0 .5rem}
38
+ .sb-tab.on{color:var(--accent);border-color:var(--accent)}
39
+ .sb-tab:hover:not(.on){color:var(--text)}
40
+ .sb-content{flex:1;overflow-y:auto;display:none}
41
+ .sb-content.on{display:flex;flex-direction:column}
42
+ .sb-title{padding:.625rem .875rem .25rem;font-size:var(--font-xs);text-transform:uppercase;color:var(--muted);letter-spacing:1px;font-weight:800}
43
+ .sb-item{padding:.375rem .75rem;cursor:grab;border-radius:4px;margin:1px .375rem;display:flex;align-items:center;gap:.5rem;font-size:var(--font-sm);transition:background .1s;user-select:none}
44
+ .sb-item:hover{background:rgba(255,255,255,.05)}
45
+ .sb-item:active{cursor:grabbing;opacity:.7}
46
+ .sb-dot{width:.5rem;height:.5rem;border-radius:2px;flex-shrink:0}
47
+ .sb-wf-item{padding:.4375rem .75rem;cursor:pointer;border-radius:4px;margin:2px .375rem;display:flex;justify-content:space-between;align-items:center;font-size:var(--font-sm);transition:background .1s}
48
+ .sb-wf-item:hover{background:rgba(255,255,255,.05)}
49
+ .sb-wf-del{color:var(--muted);cursor:pointer;font-weight:700;font-size:.8125rem;line-height:1}
50
+ .sb-wf-del:hover{color:var(--danger)}
51
+ .sb-empty{padding:1rem .75rem;font-size:var(--font-xs);color:#2a3040;text-align:center;font-style:italic}
52
+ /* MAIN */
53
+ #main{flex:1;display:flex;flex-direction:column;overflow:hidden;min-width:0}
54
+ #toolbar{height:var(--toolbar-h);background:var(--surface);border-bottom:1px solid var(--border);display:grid;grid-template-columns:1fr auto;align-items:center;flex-shrink:0;position:relative}
55
+ #tabsScroll{overflow-x:auto;overflow-y:hidden;display:flex;align-items:center;gap:1px;padding:0 .5rem;height:100%;scrollbar-width:none}
56
+ #tabsScroll::-webkit-scrollbar{display:none}
57
+ .tab{padding:.3125rem .875rem;cursor:pointer;font-size:var(--font-sm);font-weight:700;border-radius:4px 4px 0 0;background:0;border:0;color:var(--muted);transition:all .12s;display:flex;align-items:center;gap:.375rem;white-space:nowrap;flex-shrink:0;height:100%;align-items:center}
58
+ .tab.on{background:var(--surface2);color:var(--text)}
59
+ .tab:hover:not(.on){color:var(--text)}
60
+ .tab-close{font-size:.8125rem;line-height:1;opacity:.4;margin-left:.125rem;transition:opacity .1s}
61
+ .tab-close:hover{opacity:1;color:var(--danger)}
62
+ .tab-name{outline:none;border-bottom:1px dashed transparent;padding:0 .125rem;min-width:1.875rem}
63
+ .tab-name:focus{border-color:var(--accent2)}
64
+ .tb-right{display:flex;align-items:center;gap:.3125rem;padding:0 .625rem;height:100%;border-left:1px solid var(--border);flex-shrink:0}
65
+ .tbtn{padding:.25rem .625rem;border-radius:4px;font-size:var(--font-xs);cursor:pointer;border:1px solid var(--border);font-weight:700;background:var(--surface2);color:var(--muted);transition:all .12s;white-space:nowrap}
66
+ .tbtn:hover{color:var(--text);border-color:#484f58;background:var(--surface3)}
67
+ .tbtn.run{background:var(--accent);color:#fff;border-color:var(--accent)}
68
+ .tbtn.run:hover{background:#e55a5a}
69
+ .tbtn.run:disabled{background:#1a1a1a;color:#444;border-color:#2a2a2a;cursor:not-allowed}
70
+ #statusWrap{display:flex;align-items:center;gap:.3125rem;font-size:var(--font-xs);color:var(--muted);margin-left:.25rem}
71
+ .sdot{width:.375rem;height:.375rem;border-radius:50%;background:var(--muted);flex-shrink:0}
72
+ .sdot.ok{background:var(--success);box-shadow:0 0 6px rgba(63,185,80,.5)}
73
+ .sdot.load{background:var(--warning);animation:bk 1s infinite}
74
+ @keyframes bk{0%,100%{opacity:1}50%{opacity:.2}}
75
+ /* SETTINGS */
76
+ #settingsPanel{position:absolute;top:var(--toolbar-h);right:.625rem;background:var(--surface2);border:1px solid var(--border);border-radius:8px;z-index:200;min-width:13.75rem;box-shadow:0 .5rem 2rem rgba(0,0,0,.5);display:none;overflow:hidden}
77
+ #settingsPanel.open{display:block}
78
+ .sp-head{padding:.625rem .875rem;font-size:var(--font-sm);font-weight:800;color:var(--accent);border-bottom:1px solid var(--border);background:rgba(255,107,107,.04)}
79
+ .sp-body{padding:.5rem .875rem .75rem}
80
+ .sp-row{display:flex;justify-content:space-between;align-items:center;font-size:var(--font-sm);padding:.375rem 0;border-bottom:1px solid rgba(48,54,61,.4)}
81
+ .sp-row:last-child{border-bottom:none}
82
+ .sp-row label{color:var(--text);font-size:var(--font-xs)}
83
+ .sp-row input[type=checkbox]{accent-color:var(--accent);width:.9375rem;height:.9375rem;cursor:pointer}
84
+ .sp-row select,.sp-row input[type=text],.sp-row input[type=number]{background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:3px;padding:.1875rem .375rem;font-size:var(--font-xs);outline:none;cursor:pointer;max-width:8rem}
85
+ .sp-row .tbtn{padding:.1875rem .625rem}
86
+ .sp-row .zoom-row{display:flex;align-items:center;gap:.25rem}
87
+ .sp-row .zoom-row .zbtn{width:1.5rem;height:1.5rem;display:flex;align-items:center;justify-content:center;border-radius:3px;cursor:pointer;color:var(--muted);font-size:.875rem;font-weight:700;background:var(--bg);border:1px solid var(--border);transition:all .1s}
88
+ .sp-row .zoom-row .zbtn:hover{color:var(--text);background:var(--surface3)}
89
+ .sp-row .zoom-row .zoom-val{font-size:var(--font-xs);color:var(--text);font-weight:700;min-width:2.5rem;text-align:center;background:var(--bg);border:1px solid var(--border);border-radius:3px;padding:.1875rem .375rem;cursor:pointer}
90
+ /* WORKSPACE */
91
+ #wfPanel{flex:1;position:relative;overflow:hidden}
92
+ #wfC{width:100%;height:100%;position:relative;overflow:hidden;outline:none;cursor:default}
93
+ #wfInner{position:absolute;top:0;left:0;width:0;height:0;transform-origin:0 0}
94
+ #connCvs{position:absolute;inset:0;z-index:5;pointer-events:none}
95
+ .wf-hint{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);color:#161e2e;font-size:.8125rem;font-weight:800;letter-spacing:1.5px;text-transform:uppercase;pointer-events:none;z-index:0;user-select:none}
96
+ /* MINIMAP */
97
+ #minimap{position:absolute;bottom:.75rem;right:.75rem;z-index:50;width:10.625rem;height:7.1875rem;background:rgba(22,27,34,.85);border:1px solid var(--border);border-radius:5px;overflow:hidden;cursor:pointer;backdrop-filter:blur(4px)}
98
+ #minimap canvas{width:100%;height:100%;display:block}
99
+ /* POPUPS */
100
+ .popup{position:absolute;background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:.25rem;z-index:300;min-width:10.625rem;box-shadow:0 .5rem 2rem rgba(0,0,0,.55);display:none}
101
+ .ctx-item{padding:.375rem .625rem;font-size:var(--font-sm);cursor:pointer;border-radius:4px;display:flex;justify-content:space-between;align-items:center;transition:background .08s}
102
+ .ctx-item:hover{background:rgba(255,255,255,.06)}
103
+ .ctx-item.danger{color:var(--danger)}
104
+ .ctx-sep{height:1px;background:var(--border);margin:.25rem .5rem}
105
+ .ctx-label{padding:.25rem .625rem;font-size:var(--font-xs);color:var(--muted);font-weight:800;text-transform:uppercase;letter-spacing:.5px}
106
+ .ctx-sub{font-size:var(--font-xs);color:var(--muted)}
107
+ #searchMenu{width:13.75rem;padding:.375rem}
108
+ #searchInput{width:100%;background:var(--bg);border:1px solid var(--border);color:var(--text);padding:.4375rem .625rem;border-radius:4px;font-size:var(--font-sm);outline:none}
109
+ #searchInput:focus{border-color:var(--accent2)}
110
+ .search-item{padding:.375rem .5rem;font-size:var(--font-sm);cursor:pointer;border-radius:4px;display:flex;align-items:center;gap:.4375rem;transition:background .08s}
111
+ .search-item:hover{background:rgba(255,255,255,.06)}
112
+ /* NODES */
113
+ .node{position:absolute;min-width:11.25rem;max-width:20rem;background:var(--surface2);border:2px solid var(--border);border-radius:8px;user-select:none;z-index:10;box-shadow:0 .25rem 1.5rem rgba(0,0,0,.35);display:flex;flex-direction:column;overflow:visible}
114
+ .node.sel{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent),0 .25rem 1.5rem rgba(0,0,0,.4)}
115
+ .node.running{border-color:var(--warning);animation:npulse 1s ease-in-out infinite}
116
+ .node.done{border-color:var(--success);box-shadow:0 0 12px rgba(63,185,80,.2)}
117
+ .node.err{border-color:var(--danger);box-shadow:0 0 12px rgba(248,81,73,.2)}
118
+ .node.disabled{opacity:.45;filter:grayscale(.7)}
119
+ @keyframes npulse{0%,100%{box-shadow:0 0 6px rgba(210,153,34,.1)}50%{box-shadow:0 0 24px rgba(210,153,34,.4)}}
120
+ .nhdr{padding:.375rem .625rem;border-radius:6px 6px 0 0;font-size:var(--font-xs);font-weight:800;display:flex;align-items:center;justify-content:space-between;color:#fff;cursor:move;letter-spacing:.3px;flex-shrink:0;gap:.375rem}
121
+ .nhdr-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
122
+ .ndel{cursor:pointer;opacity:0;font-size:.9375rem;line-height:1;transition:opacity .1s;flex-shrink:0;width:1rem;text-align:center}
123
+ .node:hover .ndel{opacity:.5}
124
+ .ndel:hover{opacity:1!important}
125
+ .nbody{padding:.3125rem 0 .4375rem;flex:1;overflow:visible}
126
+ .npr{display:flex;align-items:center;padding:.125rem 0;font-size:var(--font-xs);color:var(--muted);position:relative}
127
+ .npr.inp{justify-content:flex-start;padding-left:0}
128
+ .npr.outp{justify-content:flex-end;padding-right:0}
129
+ .port{width:.8125rem;height:.8125rem;border-radius:50%;border:2px solid var(--border);background:var(--bg);cursor:crosshair;transition:all .12s;flex-shrink:0;position:relative;z-index:20}
130
+ .port:hover{transform:scale(1.5);border-color:var(--accent);background:rgba(255,107,107,.25);box-shadow:0 0 8px rgba(255,107,107,.3)}
131
+ .port.conn{border-color:#5a6a8a;background:#3a4a6a}
132
+ .npr.inp .port{margin-left:-.4375rem;margin-right:.375rem}
133
+ .npr.outp .port{margin-right:-.4375rem;margin-left:.375rem}
134
+ .plbl{font-weight:600;font-size:var(--font-xs)}
135
+ .nfile{padding:.1875rem .625rem;font-size:var(--font-xs);display:flex;gap:.3125rem;align-items:center;flex-wrap:wrap}
136
+ .nfile input[type=file]{display:none}
137
+ .fbtn,.sbtn{display:inline-block;padding:.1875rem .5625rem;border-radius:3px;cursor:pointer;font-size:var(--font-xs);font-weight:700;transition:all .1s}
138
+ .fbtn{background:var(--border);color:var(--text)}.fbtn:hover{background:#484f58}
139
+ .sbtn{background:#0f2a2a;color:var(--accent2);border:1px solid rgba(78,205,196,.2)}.sbtn:hover{background:#1a3a3a}
140
+ .nparam{padding:.125rem .625rem}
141
+ .nparam label{font-size:var(--font-xs);color:var(--muted);display:flex;justify-content:space-between;margin-bottom:1px}
142
+ .nparam input[type=range]{width:100%;accent-color:var(--accent);height:.25rem;cursor:pointer}
143
+ .pv{font-size:var(--font-xs);color:var(--accent);font-weight:700;min-width:1.875rem;text-align:right}
144
+ .nprev{margin:.25rem .5rem;border-radius:4px;overflow:hidden;background:#060a10;min-height:2rem;max-height:6.25rem;display:flex;align-items:center;justify-content:center}
145
+ .nprev img{max-width:100%;max-height:5.75rem;display:block;object-fit:contain}
146
+ .nprev .ph{color:#1a2030;font-size:var(--font-xs);padding:.625rem;text-align:center;font-style:italic}
147
+ .nval-display{margin:.25rem .5rem;padding:.5rem;border-radius:4px;background:rgba(33,150,243,.1);border:1px solid rgba(33,150,243,.2);text-align:center;font-size:1.25rem;font-weight:800;color:#2196F3}
148
+ .nres{position:absolute;bottom:0;right:0;width:.875rem;height:.875rem;cursor:nwse-resize;z-index:30;opacity:0;transition:opacity .15s}
149
+ .node:hover .nres{opacity:.3}
150
+ .nres:hover{opacity:.8!important}
151
+ .nres::after{content:"";position:absolute;bottom:3px;right:3px;width:7px;height:7px;border-right:2px solid var(--muted);border-bottom:2px solid var(--muted)}
152
+ /* RESPONSIVE */
153
+ @media(max-width:768px){
154
+ :root{--sidebar-w:0px}
155
+ #sidebar{position:absolute;left:0;top:0;bottom:0;z-index:150;width:0;overflow:hidden;transition:width .2s}
156
+ #sidebar.open{width:clamp(180px,60vw,260px)}
157
+ #sidebarToggle{display:flex!important}
158
+ #minimap{width:7rem;height:4.75rem}
159
+ }
160
+ #sidebarToggle{display:none;align-items:center;justify-content:center;width:2rem;height:2rem;cursor:pointer;color:var(--muted);font-size:1.125rem;border:0;background:0;border-radius:4px;transition:all .1s}
161
+ #sidebarToggle:hover{color:var(--text);background:rgba(255,255,255,.05)}
162
+ ::-webkit-scrollbar{width:5px}
163
+ ::-webkit-scrollbar-track{background:transparent}
164
+ ::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
165
+ ::-webkit-scrollbar-thumb:hover{background:#484f58}
166
+ </style>
167
+ </head>
168
+ <body>
169
+ <div id="loader"><div class="spinner"></div><div id="ldMsg">Loading Pyodide runtime...</div><div id="ldSub"></div></div>
170
+ <div id="toast"></div>
171
+ <div id="app">
172
+ <div id="sidebar">
173
+ <div class="sb-tabs">
174
+ <div class="sb-tab on" data-sb="nodes">Nodes</div>
175
+ <div class="sb-tab" data-sb="library">Library</div>
176
+ </div>
177
+ <div class="sb-content on" id="sbNodes"></div>
178
+ <div class="sb-content" id="sbLibrary">
179
+ <div class="sb-title">Saved Workflows</div>
180
+ <div id="wfList"><div class="sb-empty">No saved workflows yet</div></div>
181
+ </div>
182
+ </div>
183
+ <div id="main">
184
+ <div id="toolbar">
185
+ <div style="display:flex;align-items:center">
186
+ <div id="sidebarToggle" onclick="document.getElementById('sidebar').classList.toggle('open')">&#9776;</div>
187
+ <div id="tabsScroll"></div>
188
+ </div>
189
+ <div class="tb-right">
190
+ <button class="tbtn" id="btnAddTab" title="New tab">+</button>
191
+ <div style="width:1px;height:1.125rem;background:var(--border)"></div>
192
+ <button class="tbtn" id="btnSettings" title="Settings">&#9881;</button>
193
+ <button class="tbtn" id="btnSave" title="Save">Save</button>
194
+ <button class="tbtn" id="btnClear" title="Clear">Clear</button>
195
+ <button class="tbtn run" id="btnRun" disabled>Run</button>
196
+ <div id="statusWrap"><span class="sdot load" id="sDot"></span><span id="sTxt">Loading...</span></div>
197
+ </div>
198
+ <div id="settingsPanel">
199
+ <div class="sp-head">Settings</div>
200
+ <div class="sp-body">
201
+ <div class="sp-row"><label>Force run all</label><input type="checkbox" id="forceRunCheck"></div>
202
+ <div class="sp-row"><label>Grid size</label><select id="gridSizeSel"><option value="15">Tiny</option><option value="25" selected>Small</option><option value="40">Medium</option><option value="60">Large</option></select></div>
203
+ <div class="sp-row"><label>Snap to grid</label><input type="checkbox" id="snapCheck"></div>
204
+ <div class="sp-row"><label>Zoom</label>
205
+ <div class="zoom-row">
206
+ <button class="zbtn" onclick="zoomStep(-1)" title="Zoom out">&minus;</button>
207
+ <span class="zoom-val" id="zoomPct" onclick="this.focus()" onkeydown="if(event.key==='Enter'){setZoomPct(this);event.preventDefault()}" onblur="setZoomPct(this)">100%</span>
208
+ <button class="zbtn" onclick="zoomStep(1)" title="Zoom in">+</button>
209
+ </div>
210
+ </div>
211
+ <div class="sp-row"><label>Reset view</label><button class="tbtn" onclick="resetZoom()">Reset</button></div>
212
+ <div class="sp-row"><label>Fit all nodes</label><button class="tbtn" onclick="fitAll()">Fit</button></div>
213
+ </div>
214
+ </div>
215
+ </div>
216
+ <div id="wfPanel">
217
+ <div id="wfC" tabindex="0">
218
+ <div id="wfInner"></div>
219
+ <canvas id="connCvs"></canvas>
220
+ <div class="wf-hint" id="wfHint">Drag nodes here or double-click to add</div>
221
+ </div>
222
+ <div id="minimap" title="Click to navigate"><canvas id="minimapCvs"></canvas></div>
223
+ <div class="popup" id="ctxMenu"></div>
224
+ <div class="popup" id="searchMenu"><input type="text" id="searchInput" placeholder="Search nodes..."><div id="searchResults"></div></div>
225
+ </div>
226
+ </div>
227
+ </div>
228
+ <script>
229
+ /* ═══════════════════════════════════════════
230
+ PYTHON: Graph + Image Processing
231
+ ═══════════════════════════════════════════ */
232
+ const PY_GRAPH = `
233
+ import json
234
+ NODE_DEFS = {
235
+ "image_input": {"name": "Image Input", "color": "#4CAF50", "ins": [], "outs": ["image"], "params": [], "file": True, "value_node": False},
236
+ "image_output": {"name": "Image Output", "color": "#FF5722", "ins": ["image"], "outs": [], "params": [], "file": False, "value_node": False},
237
+ "grayscale": {"name": "Grayscale", "color": "#607D8B", "ins": ["image"], "outs": ["image"], "params": [], "file": False, "value_node": False},
238
+ "gaussian_blur": {"name": "Gaussian Blur", "color": "#FF9800", "ins": ["image"], "outs": ["image"], "params": [{"id":"sigma","label":"Sigma","min":0.1,"max":10,"step":0.1,"def":1.5}], "file": False, "value_node": False},
239
+ "sobel": {"name": "Sobel Edge", "color": "#009688", "ins": ["image"], "outs": ["image"], "params": [], "file": False, "value_node": False},
240
+ "canny": {"name": "Canny Edge", "color": "#3F51B5", "ins": ["image"], "outs": ["image"], "params": [{"id":"sigma","label":"Sigma","min":0.1,"max":5,"step":0.1,"def":1.0},{"id":"low_t","label":"Low Thresh","min":0,"max":1,"step":0.01,"def":0.1},{"id":"high_t","label":"High Thresh","min":0,"max":1,"step":0.01,"def":0.3}], "file": False, "value_node": False},
241
+ "threshold": {"name": "Otsu Threshold","color": "#795548", "ins": ["image"], "outs": ["image"], "params": [], "file": False, "value_node": False},
242
+ "invert": {"name": "Invert", "color": "#E91E63", "ins": ["image"], "outs": ["image"], "params": [], "file": False, "value_node": False},
243
+ "contour": {"name": "Contour Detect","color": "#9C27B0", "ins": ["image"], "outs": ["image"], "params": [{"id":"level","label":"Level","min":0,"max":1,"step":0.01,"def":0.5}], "file": False, "value_node": False},
244
+ "rotate": {"name": "Rotate", "color": "#00BCD4", "ins": ["image"], "outs": ["image"], "params": [{"id":"angle","label":"Angle","min":-180,"max":180,"step":1,"def":90}], "file": False, "value_node": False},
245
+ "resize": {"name": "Resize", "color": "#8BC34A", "ins": ["image"], "outs": ["image"], "params": [{"id":"scale","label":"Scale","min":0.1,"max":3,"step":0.1,"def":0.5}], "file": False, "value_node": False},
246
+ "int_value": {"name": "Int Value", "color": "#2196F3", "ins": [], "outs": ["value"], "params": [{"id":"value","label":"Value","min":-9999,"max":9999,"step":1,"def":0}], "file": False, "value_node": True},
247
+ "float_value": {"name": "Float Value", "color": "#03A9F4", "ins": [], "outs": ["value"], "params": [{"id":"value","label":"Value","min":-9999,"max":9999,"step":0.01,"def":0.0}], "file": False, "value_node": True}
248
+ }
249
+ class WorkflowGraph:
250
+ def __init__(self):
251
+ self.nodes = {}
252
+ self.edges = []
253
+ def add_node(self, nid, ntype):
254
+ nid = int(nid)
255
+ params = {p["id"]: p["def"] for p in NODE_DEFS.get(ntype, {}).get("params", [])}
256
+ self.nodes[nid] = {"type": ntype, "params": params, "promoted": [], "disabled": False}
257
+ return True
258
+ def remove_node(self, nid):
259
+ nid = int(nid)
260
+ self.nodes.pop(nid, None)
261
+ self.edges = [e for e in self.edges if int(e["fn"]) != nid and int(e["tn"]) != nid]
262
+ def update_param(self, nid, pid, val):
263
+ nid = int(nid)
264
+ if nid in self.nodes: self.nodes[nid]["params"][pid] = val
265
+ def toggle_disable(self, nid):
266
+ nid = int(nid)
267
+ if nid in self.nodes:
268
+ self.nodes[nid]["disabled"] = not self.nodes[nid]["disabled"]
269
+ return self.nodes[nid]["disabled"]
270
+ return False
271
+ def promote_param(self, nid, pid):
272
+ nid = int(nid)
273
+ node = self.nodes.get(nid)
274
+ if node and pid in node.get("params",{}) and pid not in node.get("promoted",[]):
275
+ node.setdefault("promoted",[]).append(pid)
276
+ return True
277
+ return False
278
+ def demote_param(self, nid, pid):
279
+ nid = int(nid)
280
+ node = self.nodes.get(nid)
281
+ if node and pid in node.get("promoted",[]):
282
+ node["promoted"].remove(pid)
283
+ return True
284
+ return False
285
+ def get_inputs(self, nid):
286
+ nid = int(nid)
287
+ node = self.nodes.get(nid)
288
+ if not node: return []
289
+ base = list(NODE_DEFS.get(node["type"], {}).get("ins", []))
290
+ for p in node.get("promoted", []): base.append(p)
291
+ return base
292
+ def get_outs(self, nid):
293
+ nid = int(nid)
294
+ node = self.nodes.get(nid)
295
+ if not node: return []
296
+ return list(NODE_DEFS.get(node["type"], {}).get("outs", []))
297
+ def is_value_node(self, nid):
298
+ nid = int(nid)
299
+ node = self.nodes.get(nid)
300
+ if not node: return False
301
+ return NODE_DEFS.get(node["type"], {}).get("value_node", False)
302
+ def add_edge(self, fn, fp, tn, tp):
303
+ fn, fp, tn, tp = int(fn), int(fp), int(tn), int(tp)
304
+ if fn == tn: return False
305
+ if self._would_cycle(fn, tn): return False
306
+ self.edges = [e for e in self.edges if not (int(e["tn"]) == tn and int(e["tp"]) == tp)]
307
+ self.edges.append({"fn": fn, "fp": fp, "tn": tn, "tp": tp})
308
+ return True
309
+ def remove_edge(self, fn, fp, tn, tp):
310
+ fn, fp, tn, tp = int(fn), int(fp), int(tn), int(tp)
311
+ self.edges = [e for e in self.edges if not (int(e["fn"]) == fn and int(e["fp"]) == fp and int(e["tn"]) == tn and int(e["tp"]) == tp)]
312
+ def _would_cycle(self, fn, tn):
313
+ visited, stack = set(), [tn]
314
+ while stack:
315
+ cur = stack.pop()
316
+ if cur == fn: return True
317
+ if cur in visited: continue
318
+ visited.add(cur)
319
+ for e in self.edges:
320
+ if int(e["fn"]) == cur: stack.append(int(e["tn"]))
321
+ return False
322
+ def topological_sort(self):
323
+ int_nodes = {int(k) for k in self.nodes}
324
+ in_deg = {nid: 0 for nid in int_nodes}
325
+ adj = {nid: [] for nid in int_nodes}
326
+ for e in self.edges:
327
+ fn, tn = int(e["fn"]), int(e["tn"])
328
+ if fn in in_deg and tn in in_deg:
329
+ in_deg[tn] += 1
330
+ adj[fn].append(tn)
331
+ queue = [nid for nid, d in in_deg.items() if d == 0]
332
+ order = []
333
+ while queue:
334
+ nid = queue.pop(0)
335
+ order.append(nid)
336
+ for nb in adj.get(nid, []):
337
+ in_deg[nb] -= 1
338
+ if in_deg[nb] == 0: queue.append(nb)
339
+ return order if len(order) == len(int_nodes) else None
340
+ def get_state(self):
341
+ return {"nodes": self.nodes, "edges": self.edges}
342
+ def load_state(self, state):
343
+ self.nodes = {int(k): v for k, v in state.get("nodes", {}).items()}
344
+ for v in self.nodes.values():
345
+ v.setdefault("promoted", [])
346
+ v.setdefault("disabled", False)
347
+ self.edges = state.get("edges", [])
348
+ def clear(self):
349
+ self.nodes = {}
350
+ self.edges = []
351
+ graph = WorkflowGraph()
352
+ `;
353
+ const PY_PROCESSOR = `
354
+ import numpy as np
355
+ import base64, io, json
356
+ from PIL import Image
357
+ def process_image(input_b64, operation, params_json):
358
+ params = json.loads(params_json)
359
+ img_data = base64.b64decode(input_b64)
360
+ img = Image.open(io.BytesIO(img_data)).convert("RGBA")
361
+ arr = np.array(img, dtype=np.float64) / 255.0
362
+ rgb = arr[:, :, :3]
363
+ if operation == "grayscale":
364
+ from skimage.color import rgb2gray
365
+ result = rgb2gray(rgb)
366
+ elif operation == "gaussian_blur":
367
+ from skimage.filters import gaussian
368
+ result = gaussian(rgb, sigma=params.get("sigma", 1.5), channel_axis=-1)
369
+ elif operation == "sobel":
370
+ from skimage.filters import sobel; from skimage.color import rgb2gray
371
+ result = sobel(rgb2gray(rgb))
372
+ elif operation == "canny":
373
+ from skimage.feature import canny; from skimage.color import rgb2gray
374
+ g = rgb2gray(rgb)
375
+ result = canny(g, sigma=params.get("sigma",1.0), low_threshold=params.get("low_t",0.1), high_threshold=params.get("high_t",0.3)).astype(np.float64)
376
+ elif operation == "threshold":
377
+ from skimage.filters import threshold_otsu; from skimage.color import rgb2gray
378
+ g = rgb2gray(rgb)
379
+ result = (g > threshold_otsu(g)).astype(np.float64)
380
+ elif operation == "invert":
381
+ result = 1.0 - rgb
382
+ elif operation == "contour":
383
+ from skimage.measure import find_contours; from skimage.color import rgb2gray
384
+ g = rgb2gray(rgb)
385
+ contours = find_contours(g, level=params.get("level", 0.5))
386
+ result = np.zeros_like(g)
387
+ for c in contours:
388
+ rr = np.clip(np.round(c[:,0]).astype(int), 0, result.shape[0]-1)
389
+ cc = np.clip(np.round(c[:,1]).astype(int), 0, result.shape[1]-1)
390
+ result[rr, cc] = 1.0
391
+ elif operation == "rotate":
392
+ from skimage.transform import rotate as sk_rotate
393
+ result = sk_rotate(rgb, angle=params.get("angle", 90), resize=True, channel_axis=-1)
394
+ elif operation == "resize":
395
+ from skimage.transform import resize as sk_resize
396
+ s = params.get("scale", 0.5)
397
+ nh, nw = max(1, int(rgb.shape[0]*s)), max(1, int(rgb.shape[1]*s))
398
+ result = sk_resize(rgb, (nh, nw), channel_axis=-1, anti_aliasing=True)
399
+ else:
400
+ result = rgb
401
+ if result.ndim == 2:
402
+ result = np.stack([result]*3, axis=-1)
403
+ out = (np.clip(result, 0, 1) * 255).astype(np.uint8)
404
+ alpha = np.full((*out.shape[:2], 1), 255, dtype=np.uint8)
405
+ rgba = np.concatenate([out, alpha], axis=-1)
406
+ buf = io.BytesIO()
407
+ Image.fromarray(rgba, "RGBA").save(buf, format="PNG")
408
+ return base64.b64encode(buf.getvalue()).decode("ascii")
409
+ `;
410
+ /* ═══════════════════════════════════════════
411
+ STATE & CONFIG
412
+ ═══════════════════════════════════════════ */
413
+ let pyodide = null, pyReady = false;
414
+ let nextNid = 1, mxZ = 10;
415
+ let zoom = 1, panX = 0, panY = 0, gridSize = 25;
416
+ const ZOOM_STEPS = [0.1,0.15,0.25,0.33,0.5,0.67,0.75,1,1.25,1.5,2,2.5,3,4];
417
+ let dragInfo = null, connInfo = null, tempEnd = null, panInfo = null;
418
+ let workflows = [], activeWfId = null, wfCounter = 1;
419
+ let nodeMeta = {};
420
+ const $ = s => document.querySelector(s);
421
+ const $$ = s => document.querySelectorAll(s);
422
+ const wfC = $('#wfC'), wfInner = $('#wfInner');
423
+ const cvs = $('#connCvs'), cx = cvs.getContext('2d');
424
+ const mmCvs = $('#minimapCvs'), mmCx = mmCvs.getContext('2d');
425
+ /* ═══════════════════════════════════════════
426
+ UTILITIES
427
+ ═══════════════════════════════════════════ */
428
+ function toast(msg, type='inf') {
429
+ const t = $('#toast'); t.textContent = msg;
430
+ t.className = type + ' show';
431
+ clearTimeout(t._t); t._t = setTimeout(() => t.className = '', 2800);
432
+ }
433
+ function py(code) { return pyReady ? pyodide.runPython(code) : null; }
434
+ function pyJson(code) { try { return JSON.parse(py(code)); } catch(e) { return null; } }
435
+ function hashStr(s) { let h = 0; for (let i = 0; i < s.length; i++) h = (h << 5) - h + s.charCodeAt(i) | 0; return h; }
436
+ function portCenter(el) {
437
+ const cr = wfC.getBoundingClientRect(), pr = el.getBoundingClientRect();
438
+ return { x: pr.left + pr.width/2 - cr.left, y: pr.top + pr.height/2 - cr.top };
439
+ }
440
+ function viewportCenter() {
441
+ const r = wfC.getBoundingClientRect();
442
+ return { x: (r.width/2 - panX) / zoom, y: (r.height/2 - panY) / zoom };
443
+ }
444
+ /* ═══════════════════════════════════════════
445
+ CANVAS / GRID / ZOOM
446
+ ═══════════════════════════════════════════ */
447
+ function resizeCvs() {
448
+ cvs.width = wfC.clientWidth; cvs.height = wfC.clientHeight;
449
+ mmCvs.width = 170; mmCvs.height = 115;
450
+ updateGrid(); drawConns(); drawMinimap();
451
+ }
452
+ new ResizeObserver(resizeCvs).observe(wfC);
453
+ function updateGrid() {
454
+ const s = gridSize * zoom;
455
+ const ox = ((panX % s) + s) % s, oy = ((panY % s) + s) % s;
456
+ wfC.style.backgroundImage = 'radial-gradient(circle, rgba(48,54,61,0.55) 1px, transparent 1px)';
457
+ wfC.style.backgroundSize = s + 'px ' + s + 'px';
458
+ wfC.style.backgroundPosition = ox + 'px ' + oy + 'px';
459
+ }
460
+ function applyTransform() {
461
+ wfInner.style.transform = `translate(${panX}px,${panY}px) scale(${zoom})`;
462
+ updateGrid(); drawConns(); drawMinimap(); updateZoomPct();
463
+ }
464
+ function resetZoom() { zoom = 1; panX = 0; panY = 0; applyTransform(); }
465
+ function zoomStep(dir) {
466
+ const idx = ZOOM_STEPS.reduce((best, v, i) => Math.abs(v - zoom) < Math.abs(ZOOM_STEPS[best] - zoom) ? i : best, 0);
467
+ const ni = Math.max(0, Math.min(ZOOM_STEPS.length - 1, idx + dir));
468
+ const nz = ZOOM_STEPS[ni];
469
+ const r = wfC.getBoundingClientRect();
470
+ const cx = r.width / 2, cy = r.height / 2;
471
+ panX = cx - (cx - panX) * (nz / zoom);
472
+ panY = cy - (cy - panY) * (nz / zoom);
473
+ zoom = nz; applyTransform();
474
+ }
475
+ function updateZoomPct() { $('#zoomPct').textContent = Math.round(zoom * 100) + '%'; }
476
+ function setZoomPct(el) {
477
+ const v = parseInt(el.textContent);
478
+ if (!isNaN(v) && v > 0) {
479
+ const nz = Math.max(0.1, Math.min(4, v / 100));
480
+ const r = wfC.getBoundingClientRect();
481
+ const cx = r.width/2, cy = r.height/2;
482
+ panX = cx - (cx - panX) * (nz / zoom);
483
+ panY = cy - (cy - panY) * (nz / zoom);
484
+ zoom = nz; applyTransform();
485
+ } else updateZoomPct();
486
+ }
487
+ function fitAll() {
488
+ const keys = Object.keys(nodeMeta);
489
+ if (!keys.length) { resetZoom(); return; }
490
+ let x1=Infinity, y1=Infinity, x2=-Infinity, y2=-Infinity;
491
+ keys.forEach(k => { const m = nodeMeta[k]; x1=Math.min(x1,m.x); y1=Math.min(y1,m.y); x2=Math.max(x2,m.x+m.w); y2=Math.max(y2,m.y+m.h); });
492
+ const pad = 60, cw = x2-x1+pad*2, ch = y2-y1+pad*2;
493
+ const vw = wfC.clientWidth, vh = wfC.clientHeight;
494
+ zoom = Math.max(0.15, Math.min(vw/cw, vh/ch, 2));
495
+ panX = (vw - cw*zoom)/2 - (x1-pad)*zoom;
496
+ panY = (vh - ch*zoom)/2 - (y1-pad)*zoom;
497
+ applyTransform();
498
+ }
499
+ /* ═══════════════════════════════════════════
500
+ CONNECTION DRAWING
501
+ ═══════════════════════════════════════════ */
502
+ function bezier(x1, y1, x2, y2, col, alpha) {
503
+ const cp = Math.max(40, Math.abs(x2-x1) * 0.45);
504
+ cx.save();
505
+ cx.globalAlpha = alpha * 0.12;
506
+ cx.beginPath(); cx.moveTo(x1,y1); cx.bezierCurveTo(x1+cp,y1, x2-cp,y2, x2,y2);
507
+ cx.strokeStyle = col; cx.lineWidth = 8; cx.stroke();
508
+ cx.globalAlpha = alpha * 0.85;
509
+ cx.beginPath(); cx.moveTo(x1,y1); cx.bezierCurveTo(x1+cp,y1, x2-cp,y2, x2,y2);
510
+ cx.strokeStyle = col; cx.lineWidth = 2.5; cx.lineCap = 'round'; cx.stroke();
511
+ cx.restore();
512
+ }
513
+ function drawConns() {
514
+ cx.clearRect(0, 0, cvs.width, cvs.height);
515
+ if (!pyReady) return;
516
+ const edges = pyJson('json.dumps(graph.edges)');
517
+ if (edges) edges.forEach(c => {
518
+ const fp = wfInner.querySelector(`.port[data-nid="${c.fn}"][data-pt="out"][data-pi="${c.fp}"]`);
519
+ const tp = wfInner.querySelector(`.port[data-nid="${c.tn}"][data-pt="in"][data-pi="${c.tp}"]`);
520
+ if (fp && tp) { const a = portCenter(fp), b = portCenter(tp); bezier(a.x, a.y, b.x, b.y, '#ff6b6b', 1); }
521
+ });
522
+ if (connInfo && tempEnd) {
523
+ const p = portCenter(connInfo.el);
524
+ if (connInfo.ptype === 'out') bezier(p.x, p.y, tempEnd.x, tempEnd.y, '#ff6b6b', 0.45);
525
+ else bezier(tempEnd.x, tempEnd.y, p.x, p.y, '#4ecdc4', 0.45);
526
+ }
527
+ }
528
+ function updPortStyles() {
529
+ if (!pyReady) return;
530
+ wfInner.querySelectorAll('.port').forEach(p => p.classList.remove('conn'));
531
+ const edges = pyJson('json.dumps(graph.edges)');
532
+ if (edges) edges.forEach(c => {
533
+ const fp = wfInner.querySelector(`.port[data-nid="${c.fn}"][data-pt="out"][data-pi="${c.fp}"]`);
534
+ const tp = wfInner.querySelector(`.port[data-nid="${c.tn}"][data-pt="in"][data-pi="${c.tp}"]`);
535
+ if (fp) fp.classList.add('conn');
536
+ if (tp) tp.classList.add('conn');
537
+ });
538
+ }
539
+ function updHint() {
540
+ const n = pyReady ? pyJson('len(graph.nodes)') : 0;
541
+ $('#wfHint').style.display = n ? 'none' : '';
542
+ }
543
+ /* ═══════════════════════════════════════════
544
+ MINIMAP
545
+ ═══════════════════════════════════════════ */
546
+ let mmData = null;
547
+ function drawMinimap() {
548
+ mmCx.fillStyle = '#0d1117'; mmCx.fillRect(0, 0, 170, 115);
549
+ if (!pyReady) return;
550
+ const nids = pyJson('list(graph.nodes.keys())');
551
+ if (!nids || !nids.length) { mmData = null; return; }
552
+ let x1=Infinity, y1=Infinity, x2=-Infinity, y2=-Infinity;
553
+ nids.forEach(nid => { const m = nodeMeta[nid]; if(m){x1=Math.min(x1,m.x);y1=Math.min(y1,m.y);x2=Math.max(x2,m.x+m.w);y2=Math.max(y2,m.y+m.h);} });
554
+ const pad = 80; x1-=pad; y1-=pad; x2+=pad; y2+=pad;
555
+ const w=x2-x1, h=y2-y1, sc=Math.min(170/w, 115/h);
556
+ const ox=(170-w*sc)/2, oy=(115-h*sc)/2;
557
+ mmData = {minX:x1, minY:y1, scale:sc, ox, oy};
558
+ nids.forEach(nid => {
559
+ const m = nodeMeta[nid]; if(!m) return;
560
+ const st = pyJson(`json.dumps(graph.nodes[${nid}])`);
561
+ const td = pyJson(`json.dumps(NODE_DEFS["${st.type}"])`);
562
+ mmCx.fillStyle = (td.color||'#666') + '88';
563
+ mmCx.fillRect(ox+(m.x-x1)*sc, oy+(m.y-y1)*sc, Math.max(2,m.w*sc), Math.max(2,m.h*sc));
564
+ });
565
+ const vx=-panX/zoom, vy=-panY/zoom, vw=wfC.clientWidth/zoom, vh=wfC.clientHeight/zoom;
566
+ mmCx.strokeStyle='#ff6b6baa'; mmCx.lineWidth=1.5;
567
+ mmCx.strokeRect(ox+(vx-x1)*sc, oy+(vy-y1)*sc, vw*sc, vh*sc);
568
+ }
569
+ $('#minimap').addEventListener('click', e => {
570
+ if (!mmData) return;
571
+ const rect = e.currentTarget.getBoundingClientRect();
572
+ const wx = (e.clientX-rect.left-mmData.ox)/mmData.scale + mmData.minX;
573
+ const wy = (e.clientY-rect.top-mmData.oy)/mmData.scale + mmData.minY;
574
+ panX = wfC.clientWidth/2 - wx*zoom; panY = wfC.clientHeight/2 - wy*zoom;
575
+ applyTransform();
576
+ });
577
+ /* ═══════════════════════════════════════════
578
+ ZOOM (wheel)
579
+ ═══════════════════════════════════════════ */
580
+ wfC.addEventListener('wheel', e => {
581
+ e.preventDefault();
582
+ const nz = Math.max(0.1, Math.min(4, zoom * (e.deltaY > 0 ? 0.92 : 1.08)));
583
+ const r = wfC.getBoundingClientRect();
584
+ const mx = e.clientX-r.left, my = e.clientY-r.top;
585
+ panX = mx-(mx-panX)*(nz/zoom); panY = my-(my-panY)*(nz/zoom);
586
+ zoom = nz; applyTransform();
587
+ }, { passive: false });
588
+ /* ═══════════════════════════════════════════
589
+ NODE CREATION
590
+ ═══════════════════════════════════════════ */
591
+ function addNode(type, x, y, nidOverride, nodeState) {
592
+ const nid = nidOverride != null ? nidOverride : nextNid++;
593
+ if (nodeState) py(`graph.nodes[${nid}] = ${JSON.stringify(nodeState)}`);
594
+ else py(`graph.add_node(${nid}, "${type}")`);
595
+ const td = pyJson(`json.dumps(NODE_DEFS["${type}"])`);
596
+ const isValue = td.value_node;
597
+ nodeMeta[nid] = { x, y, w: isValue ? 160 : 190, h: isValue ? 100 : 120 };
598
+ const el = document.createElement('div');
599
+ el.className = 'node' + ((nodeState && nodeState.disabled) ? ' disabled' : '');
600
+ el.dataset.nid = nid;
601
+ el.style.left = x + 'px'; el.style.top = y + 'px';
602
+ if (nodeMeta[nid].w) el.style.width = nodeMeta[nid].w + 'px';
603
+ const hdr = document.createElement('div');
604
+ hdr.className = 'nhdr'; hdr.style.background = td.color;
605
+ hdr.innerHTML = `<span class="nhdr-title">${td.name}</span><span class="ndel" title="Delete node">&times;</span>`;
606
+ el.appendChild(hdr);
607
+ const body = document.createElement('div'); body.className = 'nbody'; el.appendChild(body);
608
+ const res = document.createElement('div'); res.className = 'nres'; el.appendChild(res);
609
+ wfInner.appendChild(el);
610
+ renderNodeBody(nid);
611
+ updHint(); drawMinimap();
612
+ return nid;
613
+ }
614
+ function renderNodeBody(nid) {
615
+ const el = wfInner.querySelector(`.node[data-nid="${nid}"]`);
616
+ if (!el) return;
617
+ const body = el.querySelector('.nbody');
618
+ body.innerHTML = '';
619
+ const state = pyJson(`json.dumps(graph.nodes[${nid}])`);
620
+ const td = pyJson(`json.dumps(NODE_DEFS["${state.type}"])`);
621
+ const ins = pyJson(`json.dumps(graph.get_inputs(${nid}))`);
622
+ const outs = pyJson(`json.dumps(graph.get_outs(${nid}))`);
623
+ const isValue = td.value_node;
624
+ // Preserve classes
625
+ const wasSel = el.classList.contains('sel');
626
+ el.className = 'node' + (state.disabled ? ' disabled' : '') + (wasSel ? ' sel' : '');
627
+ // File controls
628
+ if (td.file) {
629
+ const fd = document.createElement('div'); fd.className = 'nfile';
630
+ const fi = document.createElement('input'); fi.type = 'file'; fi.accept = 'image/*';
631
+ fi.addEventListener('change', e => handleUpload(nid, e));
632
+ const fb = document.createElement('span'); fb.className = 'fbtn'; fb.textContent = 'Upload';
633
+ fb.addEventListener('click', () => fi.click());
634
+ const sb = document.createElement('span'); sb.className = 'sbtn'; sb.textContent = 'Sample';
635
+ sb.addEventListener('click', () => loadSample(nid));
636
+ fd.append(fi, fb, sb); body.appendChild(fd);
637
+ }
638
+ // Input ports
639
+ ins.forEach((nm, i) => {
640
+ const row = document.createElement('div'); row.className = 'npr inp';
641
+ const pt = document.createElement('div'); pt.className = 'port';
642
+ pt.dataset.nid = nid; pt.dataset.pt = 'in'; pt.dataset.pi = i; pt.dataset.pn = nm;
643
+ const lb = document.createElement('span'); lb.className = 'plbl'; lb.textContent = nm;
644
+ row.append(pt, lb); body.appendChild(row);
645
+ });
646
+ // Params (non-promoted)
647
+ (td.params || []).forEach(p => {
648
+ if (state.promoted && state.promoted.includes(p.id)) return;
649
+ const pd = document.createElement('div'); pd.className = 'nparam';
650
+ const lb = document.createElement('label');
651
+ const lt = document.createElement('span'); lt.textContent = p.label;
652
+ const pv = document.createElement('span'); pv.className = 'pv';
653
+ const val = state.params[p.id];
654
+ pv.textContent = Number.isInteger(val) ? val : parseFloat(val).toFixed(2);
655
+ lb.append(lt, pv);
656
+ const inp = document.createElement('input'); inp.type = 'range';
657
+ inp.min = p.min; inp.max = p.max; inp.step = p.step; inp.value = val;
658
+ inp.addEventListener('input', e => {
659
+ const v = parseFloat(e.target.value);
660
+ py(`graph.update_param(${nid}, "${p.id}", ${v})`);
661
+ pv.textContent = Number.isInteger(v) ? v : v.toFixed(2);
662
+ });
663
+ pd.append(lb, inp); body.appendChild(pd);
664
+ });
665
+ // Preview (image nodes) or value display (value nodes)
666
+ if (isValue) {
667
+ const vd = document.createElement('div'); vd.className = 'nval-display'; vd.id = 'prev-' + nid;
668
+ const val = state.params.value;
669
+ vd.textContent = val != null ? (Number.isInteger(val) ? val : parseFloat(val).toFixed(2)) : '0';
670
+ body.appendChild(vd);
671
+ } else {
672
+ const prev = document.createElement('div'); prev.className = 'nprev'; prev.id = 'prev-' + nid;
673
+ const m = nodeMeta[nid];
674
+ if (m && m.outB64) {
675
+ const img = document.createElement('img'); img.src = 'data:image/png;base64,' + m.outB64;
676
+ prev.appendChild(img);
677
+ } else if (m && m.imgB64) {
678
+ const img = document.createElement('img'); img.src = 'data:image/png;base64,' + m.imgB64;
679
+ prev.appendChild(img);
680
+ } else {
681
+ prev.innerHTML = '<div class="ph">No image</div>';
682
+ }
683
+ body.appendChild(prev);
684
+ }
685
+ // Output ports
686
+ outs.forEach((nm, i) => {
687
+ const row = document.createElement('div'); row.className = 'npr outp';
688
+ const lb = document.createElement('span'); lb.className = 'plbl'; lb.textContent = nm;
689
+ const pt = document.createElement('div'); pt.className = 'port';
690
+ pt.dataset.nid = nid; pt.dataset.pt = 'out'; pt.dataset.pi = i; pt.dataset.pn = nm;
691
+ row.append(lb, pt); body.appendChild(row);
692
+ });
693
+ // Port listeners
694
+ body.querySelectorAll('.port').forEach(pt => {
695
+ pt.addEventListener('mousedown', e => {
696
+ e.preventDefault(); e.stopPropagation();
697
+ connInfo = { nid: parseInt(pt.dataset.nid), ptype: pt.dataset.pt, pidx: parseInt(pt.dataset.pi), el: pt };
698
+ });
699
+ });
700
+ }
701
+ /* ═══════════════════════════════════════════
702
+ NODE DELETE (event delegation for cross btn)
703
+ ═══════════════════════════════════════════ */
704
+ wfInner.addEventListener('click', e => {
705
+ if (e.target.classList.contains('ndel')) {
706
+ const nid = parseInt(e.target.closest('.node').dataset.nid);
707
+ delNode(nid);
708
+ }
709
+ });
710
+ function delNode(nid) {
711
+ py(`graph.remove_node(${nid})`);
712
+ const el = wfInner.querySelector(`.node[data-nid="${nid}"]`);
713
+ if (el) el.remove();
714
+ delete nodeMeta[nid];
715
+ updPortStyles(); drawConns(); updHint(); drawMinimap();
716
+ }
717
+ /* ═══════════════════════════════════════════
718
+ GLOBAL MOUSE HANDLERS
719
+ ═══════════════════════════════════════════ */
720
+ const PAN_THRESHOLD = 4;
721
+ document.addEventListener('mousemove', e => {
722
+ if (panInfo) {
723
+ const dx = e.clientX - panInfo.startX, dy = e.clientY - panInfo.startY;
724
+ if (!panInfo.moved && Math.abs(dx) + Math.abs(dy) > PAN_THRESHOLD) { panInfo.moved = true; wfC.style.cursor = 'grabbing'; }
725
+ if (panInfo.moved) { panX = panInfo.startPanX + dx; panY = panInfo.startPanY + dy; applyTransform(); }
726
+ return;
727
+ }
728
+ if (dragInfo) {
729
+ const dx = (e.clientX - dragInfo.lastX) / zoom, dy = (e.clientY - dragInfo.lastY) / zoom;
730
+ const m = nodeMeta[dragInfo.nid];
731
+ const el = wfInner.querySelector(`.node[data-nid="${dragInfo.nid}"]`);
732
+ if (dragInfo.resizing) {
733
+ m.w = Math.max(160, m.w + dx);
734
+ m.h = Math.max(80, m.h + dy);
735
+ if (el) { el.style.width = m.w + 'px'; }
736
+ } else {
737
+ let nx = m.x + dx, ny = m.y + dy;
738
+ if ($('#snapCheck').checked) { const gs = gridSize; nx = Math.round(nx/gs)*gs; ny = Math.round(ny/gs)*gs; }
739
+ m.x = nx; m.y = ny;
740
+ if (el) { el.style.left = nx + 'px'; el.style.top = ny + 'px'; }
741
+ }
742
+ dragInfo.lastX = e.clientX; dragInfo.lastY = e.clientY;
743
+ drawConns(); drawMinimap();
744
+ return;
745
+ }
746
+ if (connInfo) {
747
+ const cr = wfC.getBoundingClientRect();
748
+ tempEnd = { x: e.clientX-cr.left, y: e.clientY-cr.top };
749
+ drawConns();
750
+ }
751
+ });
752
+ document.addEventListener('mouseup', e => {
753
+ if (panInfo) {
754
+ wfC.style.cursor = '';
755
+ if (!panInfo.moved) { wfInner.querySelectorAll('.node.sel').forEach(n => n.classList.remove('sel')); closeCtxMenu(); closeSearchMenu(); }
756
+ panInfo = null; return;
757
+ }
758
+ if (connInfo) {
759
+ const target = e.target.closest('.port');
760
+ if (target) {
761
+ const tNid = parseInt(target.dataset.nid), tPt = target.dataset.pt, tPi = parseInt(target.dataset.pi);
762
+ if (tPt !== connInfo.ptype && tNid !== connInfo.nid) {
763
+ let fn, fp, tn, tp;
764
+ if (connInfo.ptype === 'out') { fn=connInfo.nid; fp=connInfo.pidx; tn=tNid; tp=tPi; }
765
+ else { fn=tNid; fp=tPi; tn=connInfo.nid; tp=connInfo.pidx; }
766
+ const ok = py(`graph.add_edge(${fn}, ${fp}, ${tn}, ${tp})`);
767
+ if (ok) updPortStyles();
768
+ else toast('Cannot connect: would create a cycle', 'err');
769
+ }
770
+ }
771
+ connInfo = null; tempEnd = null; drawConns();
772
+ }
773
+ dragInfo = null;
774
+ });
775
+ wfInner.addEventListener('mousedown', e => {
776
+ const nodeEl = e.target.closest('.node');
777
+ if (!nodeEl || e.target.classList.contains('ndel')) return;
778
+ const nid = parseInt(nodeEl.dataset.nid);
779
+ mxZ++; nodeEl.style.zIndex = mxZ;
780
+ if (!e.shiftKey) wfInner.querySelectorAll('.node.sel').forEach(n => n.classList.remove('sel'));
781
+ nodeEl.classList.add('sel');
782
+ if (e.target.classList.contains('nres')) {
783
+ dragInfo = { nid, resizing: true, lastX: e.clientX, lastY: e.clientY };
784
+ e.preventDefault(); return;
785
+ }
786
+ if (e.target.closest('.nhdr') && !e.target.classList.contains('ndel')) {
787
+ dragInfo = { nid, resizing: false, lastX: e.clientX, lastY: e.clientY };
788
+ e.preventDefault(); return;
789
+ }
790
+ });
791
+ wfC.addEventListener('mousedown', e => {
792
+ if (e.target.closest('.node') || e.target.closest('#minimap') || e.target.closest('.popup')) return;
793
+ if (e.button === 2) return;
794
+ panInfo = { startPanX: panX, startPanY: panY, startX: e.clientX, startY: e.clientY, moved: false };
795
+ e.preventDefault();
796
+ });
797
+ wfInner.addEventListener('contextmenu', e => {
798
+ e.preventDefault();
799
+ const nodeEl = e.target.closest('.node');
800
+ if (nodeEl) showCtxMenu(parseInt(nodeEl.dataset.nid), e);
801
+ });
802
+ /* ═══════════════════════════════════════════
803
+ CONTEXT MENU
804
+ ═══════════════════════════════════════════ */
805
+ function showCtxMenu(nid, e) {
806
+ const panel = $('#wfPanel'), pr = panel.getBoundingClientRect(), menu = $('#ctxMenu');
807
+ const state = pyJson(`json.dumps(graph.nodes[${nid}])`);
808
+ const td = pyJson(`json.dumps(NODE_DEFS["${state.type}"])`);
809
+ let html = `<div class="ctx-item danger" data-act="del">Delete Node</div>`;
810
+ html += `<div class="ctx-item" data-act="toggle">${state.disabled ? 'Enable' : 'Disable'} Node</div>`;
811
+ if (td.params && td.params.length) {
812
+ html += '<div class="ctx-sep"></div><div class="ctx-label">Parameters</div>';
813
+ td.params.forEach(p => {
814
+ const isProm = state.promoted && state.promoted.includes(p.id);
815
+ html += `<div class="ctx-item" data-act="${isProm?'demote':'promote'}" data-pid="${p.id}">${p.label} <span class="ctx-sub">${isProm?'Demote':'Promote to input'}</span></div>`;
816
+ });
817
+ }
818
+ menu.innerHTML = html;
819
+ menu.style.display = 'block';
820
+ let left = e.clientX - pr.left, top = e.clientY - pr.top;
821
+ if (left + 180 > pr.width) left = pr.width - 185;
822
+ if (top + menu.offsetHeight > pr.height) top = pr.height - menu.offsetHeight - 5;
823
+ menu.style.left = Math.max(0, left) + 'px'; menu.style.top = Math.max(0, top) + 'px';
824
+ menu.querySelectorAll('.ctx-item').forEach(item => {
825
+ item.addEventListener('click', () => {
826
+ const act = item.dataset.act, pid = item.dataset.pid;
827
+ if (act === 'del') delNode(nid);
828
+ else if (act === 'toggle') { py(`graph.toggle_disable(${nid})`); renderNodeBody(nid); }
829
+ else if (act === 'promote') { py(`graph.promote_param(${nid}, "${pid}")`); renderNodeBody(nid); updPortStyles(); drawConns(); }
830
+ else if (act === 'demote') {
831
+ const ins = pyJson(`json.dumps(graph.get_inputs(${nid}))`);
832
+ const pidx = ins.indexOf(pid);
833
+ if (pidx !== -1) py(`graph.edges = [e for e in graph.edges if not (int(e["tn"]) == ${nid} and int(e["tp"]) == ${pidx})]`);
834
+ py(`graph.demote_param(${nid}, "${pid}")`);
835
+ renderNodeBody(nid); updPortStyles(); drawConns();
836
+ }
837
+ closeCtxMenu();
838
+ });
839
+ });
840
+ }
841
+ function closeCtxMenu() { $('#ctxMenu').style.display = 'none'; }
842
+ /* ═══════════════════════════════════════════
843
+ SEARCH MENU (double-click canvas)
844
+ ═══════════════════════════════════════════ */
845
+ wfC.addEventListener('dblclick', e => {
846
+ if (e.target.closest('.node') || e.target.closest('#minimap') || e.target.closest('.popup')) return;
847
+ const pr = $('#wfPanel').getBoundingClientRect();
848
+ showSearchMenu(e.clientX - pr.left, e.clientY - pr.top, (e.clientX - pr.left - panX)/zoom - 90, (e.clientY - pr.top - panY)/zoom - 20);
849
+ });
850
+ function showSearchMenu(sx, sy, wx, wy) {
851
+ const menu = $('#searchMenu'), pr = $('#wfPanel').getBoundingClientRect();
852
+ let left = sx, top = sy;
853
+ if (left + 220 > pr.width) left = pr.width - 225;
854
+ if (top + 200 > pr.height) top = pr.height - 205;
855
+ menu.style.left = Math.max(0, left) + 'px'; menu.style.top = Math.max(0, top) + 'px';
856
+ menu.style.display = 'block'; menu._wx = wx; menu._wy = wy;
857
+ const inp = $('#searchInput'); inp.value = ''; inp.focus(); filterSearch();
858
+ }
859
+ function closeSearchMenu() { $('#searchMenu').style.display = 'none'; }
860
+ $('#searchInput').addEventListener('input', filterSearch);
861
+ $('#searchInput').addEventListener('keydown', e => { if (e.key === 'Escape') closeSearchMenu(); });
862
+ function filterSearch() {
863
+ const q = $('#searchInput').value.toLowerCase();
864
+ const defs = pyReady ? pyJson('json.dumps(NODE_DEFS)') : {};
865
+ const res = $('#searchResults');
866
+ res.innerHTML = '';
867
+ let count = 0;
868
+ for (const [type, d] of Object.entries(defs)) {
869
+ if (count >= 8) break;
870
+ if (d.name.toLowerCase().includes(q)) {
871
+ const div = document.createElement('div'); div.className = 'search-item';
872
+ div.innerHTML = `<div class="sb-dot" style="background:${d.color}"></div>${d.name}`;
873
+ div.addEventListener('click', () => {
874
+ const m = $('#searchMenu');
875
+ addNode(type, m._wx, m._wy);
876
+ closeSearchMenu();
877
+ });
878
+ res.appendChild(div); count++;
879
+ }
880
+ }
881
+ if (!count) res.innerHTML = '<div style="padding:8px;font-size:10px;color:var(--muted)">No results</div>';
882
+ }
883
+ document.addEventListener('mousedown', e => {
884
+ if (!e.target.closest('#ctxMenu')) closeCtxMenu();
885
+ if (!e.target.closest('#searchMenu') && !e.target.closest('#wfC')) closeSearchMenu();
886
+ });
887
+ /* ═══════════════════════════════════════════
888
+ SIDEBAR
889
+ ═══════════════════════════════════════════ */
890
+ const NODE_CATEGORIES = [
891
+ { title: 'Input / Output', items: [
892
+ { type: 'image_input', color: '#4CAF50', name: 'Image Input' },
893
+ { type: 'image_output', color: '#FF5722', name: 'Image Output' }
894
+ ]},
895
+ { title: 'Values', items: [
896
+ { type: 'int_value', color: '#2196F3', name: 'Int Value' },
897
+ { type: 'float_value', color: '#03A9F4', name: 'Float Value' }
898
+ ]},
899
+ { title: 'Color', items: [
900
+ { type: 'grayscale', color: '#607D8B', name: 'Grayscale' },
901
+ { type: 'invert', color: '#E91E63', name: 'Invert Colors' }
902
+ ]},
903
+ { title: 'Filters', items: [
904
+ { type: 'gaussian_blur', color: '#FF9800', name: 'Gaussian Blur' },
905
+ { type: 'threshold', color: '#795548', name: 'Otsu Threshold' }
906
+ ]},
907
+ { title: 'Edge Detection', items: [
908
+ { type: 'sobel', color: '#009688', name: 'Sobel Edge' },
909
+ { type: 'canny', color: '#3F51B5', name: 'Canny Edge' }
910
+ ]},
911
+ { title: 'Transform', items: [
912
+ { type: 'contour', color: '#9C27B0', name: 'Contour Detect' },
913
+ { type: 'rotate', color: '#00BCD4', name: 'Rotate' },
914
+ { type: 'resize', color: '#8BC34A', name: 'Resize' }
915
+ ]}
916
+ ];
917
+ function buildSidebar() {
918
+ const sb = $('#sbNodes');
919
+ sb.innerHTML = '';
920
+ NODE_CATEGORIES.forEach(cat => {
921
+ const title = document.createElement('div'); title.className = 'sb-title'; title.textContent = cat.title;
922
+ sb.appendChild(title);
923
+ cat.items.forEach(item => {
924
+ const div = document.createElement('div'); div.className = 'sb-item'; div.draggable = true;
925
+ div.dataset.type = item.type;
926
+ div.innerHTML = `<div class="sb-dot" style="background:${item.color}"></div>${item.name}`;
927
+ div.addEventListener('dragstart', e => { e.dataTransfer.setData('nodeType', item.type); e.dataTransfer.effectAllowed = 'copy'; });
928
+ div.addEventListener('dblclick', () => {
929
+ const c = viewportCenter();
930
+ addNode(item.type, c.x - 90, c.y - 20);
931
+ });
932
+ sb.appendChild(div);
933
+ });
934
+ });
935
+ }
936
+ // Sidebar tab switching
937
+ $$('.sb-tab').forEach(btn => btn.addEventListener('click', () => {
938
+ $$('.sb-tab').forEach(b => b.classList.remove('on'));
939
+ $$('.sb-content').forEach(b => b.classList.remove('on'));
940
+ btn.classList.add('on');
941
+ $(btn.dataset.sb === 'nodes' ? '#sbNodes' : '#sbLibrary').classList.add('on');
942
+ }));
943
+ // Drag & drop on canvas
944
+ wfC.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; });
945
+ wfC.addEventListener('drop', e => {
946
+ e.preventDefault();
947
+ const type = e.dataTransfer.getData('nodeType');
948
+ if (!type) return;
949
+ const cr = wfC.getBoundingClientRect();
950
+ addNode(type, (e.clientX-cr.left-panX)/zoom - 90, (e.clientY-cr.top-panY)/zoom - 20);
951
+ });
952
+ /* ═══════════════════════════════════════════
953
+ IMAGE HANDLING
954
+ ═══════════════════════════════════════════ */
955
+ function imgToB64(img) {
956
+ const maxD = 512;
957
+ let w = img.naturalWidth || img.width, h = img.naturalHeight || img.height;
958
+ if (w > maxD || h > maxD) { const s = Math.min(maxD/w, maxD/h); w = Math.round(w*s); h = Math.round(h*s); }
959
+ const c = document.createElement('canvas'); c.width = w; c.height = h;
960
+ c.getContext('2d').drawImage(img, 0, 0, w, h);
961
+ return c.toDataURL('image/png').split(',')[1];
962
+ }
963
+ function setPreview(nid, b64, val) {
964
+ const prev = $(`#prev-${nid}`);
965
+ if (!prev) return;
966
+ if (prev.classList.contains('nval-display')) {
967
+ prev.textContent = val != null ? (Number.isInteger(val) ? val : parseFloat(val).toFixed(2)) : '0';
968
+ return;
969
+ }
970
+ if (b64) { prev.innerHTML = ''; const img = document.createElement('img'); img.src = 'data:image/png;base64,' + b64; prev.appendChild(img); }
971
+ else prev.innerHTML = '<div class="ph">No image</div>';
972
+ }
973
+ function handleUpload(nid, e) {
974
+ const file = e.target.files[0]; if (!file) return;
975
+ const reader = new FileReader();
976
+ reader.onload = ev => {
977
+ const img = new Image();
978
+ img.onload = () => {
979
+ const b64 = imgToB64(img);
980
+ const m = nodeMeta[nid];
981
+ if (m) { m.imgB64 = b64; m.outB64 = b64; m.lastHash = null; }
982
+ setPreview(nid, b64);
983
+ };
984
+ img.src = ev.target.result;
985
+ };
986
+ reader.readAsDataURL(file);
987
+ }
988
+ function loadSample(nid) {
989
+ const c = document.createElement('canvas'); c.width = 320; c.height = 240;
990
+ const ctx = c.getContext('2d');
991
+ const g = ctx.createLinearGradient(0,0,320,240);
992
+ g.addColorStop(0,'#1a2a3a'); g.addColorStop(1,'#2a4a3a');
993
+ ctx.fillStyle = g; ctx.fillRect(0,0,320,240);
994
+ ctx.fillStyle = '#e74c3c'; ctx.beginPath(); ctx.arc(80,100,50,0,Math.PI*2); ctx.fill();
995
+ ctx.fillStyle = '#2ecc71'; ctx.fillRect(160,60,80,80);
996
+ ctx.fillStyle = '#f39c12'; ctx.beginPath(); ctx.moveTo(280,40); ctx.lineTo(310,120); ctx.lineTo(250,120); ctx.closePath(); ctx.fill();
997
+ ctx.fillStyle = '#9b59b6'; ctx.beginPath(); ctx.ellipse(160,180,60,30,0,0,Math.PI*2); ctx.fill();
998
+ ctx.strokeStyle = '#ecf0f1'; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(10,200); ctx.lineTo(310,200); ctx.stroke();
999
+ const b64 = c.toDataURL('image/png').split(',')[1];
1000
+ const m = nodeMeta[nid];
1001
+ if (m) { m.imgB64 = b64; m.outB64 = b64; m.lastHash = null; }
1002
+ setPreview(nid, b64);
1003
+ }
1004
+ /* ═══════════════════════════════════════════
1005
+ WORKFLOW TABS
1006
+ ═══════════════════════════════════════════ */
1007
+ function initTabs() {
1008
+ const saved = localStorage.getItem('nodeWfTabs');
1009
+ if (saved) { const s = JSON.parse(saved); workflows = s.workflows || []; wfCounter = s.counter || 1; }
1010
+ if (!workflows.length) workflows.push({ id: wfCounter++, name: 'Workflow 1' });
1011
+ activeWfId = workflows[0].id;
1012
+ renderTabs(); loadWfLibrary();
1013
+ loadCurrentWf();
1014
+ }
1015
+ function renderTabs() {
1016
+ const c = $('#tabsScroll'); c.innerHTML = '';
1017
+ workflows.forEach(wf => {
1018
+ const tab = document.createElement('div'); tab.className = 'tab' + (wf.id === activeWfId ? ' on' : '');
1019
+ const nameSpan = document.createElement('span'); nameSpan.className = 'tab-name';
1020
+ nameSpan.contentEditable = 'true'; nameSpan.spellcheck = false; nameSpan.textContent = wf.name;
1021
+ nameSpan.addEventListener('blur', () => { wf.name = nameSpan.textContent.trim() || 'Untitled'; loadWfLibrary(); });
1022
+ nameSpan.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); nameSpan.blur(); } });
1023
+ nameSpan.addEventListener('mousedown', e => e.stopPropagation());
1024
+ tab.appendChild(nameSpan);
1025
+ if (workflows.length > 1) {
1026
+ const cls = document.createElement('span'); cls.className = 'tab-close'; cls.textContent = '\u00d7';
1027
+ cls.addEventListener('click', e => { e.stopPropagation(); removeTab(wf.id); });
1028
+ tab.appendChild(cls);
1029
+ }
1030
+ tab.addEventListener('click', e => { if (!e.target.classList.contains('tab-close')) switchTab(wf.id); });
1031
+ c.appendChild(tab);
1032
+ });
1033
+ }
1034
+ function switchTab(id) {
1035
+ saveCurrentWf(); activeWfId = id;
1036
+ clearWfDOM(); py('graph.clear()'); loadCurrentWf(); renderTabs();
1037
+ }
1038
+ function addTab() {
1039
+ const id = wfCounter++; workflows.push({ id, name: 'Workflow ' + id }); switchTab(id);
1040
+ }
1041
+ function removeTab(id) {
1042
+ saveCurrentWf(); workflows = workflows.filter(w => w.id !== id);
1043
+ if (activeWfId === id) { activeWfId = workflows[0].id; clearWfDOM(); py('graph.clear()'); loadCurrentWf(); }
1044
+ renderTabs(); persistTabs();
1045
+ }
1046
+ $('#btnAddTab').addEventListener('click', addTab);
1047
+ function clearWfDOM() {
1048
+ wfInner.querySelectorAll('.node').forEach(n => n.remove());
1049
+ nodeMeta = {}; updHint(); drawConns(); drawMinimap();
1050
+ }
1051
+ function saveCurrentWf() {
1052
+ if (!pyReady) return;
1053
+ const state = py('json.dumps(graph.get_state())');
1054
+ const wf = workflows.find(w => w.id === activeWfId);
1055
+ if (wf) {
1056
+ wf.state = state;
1057
+ wf.meta = {};
1058
+ for (const [k, v] of Object.entries(nodeMeta)) {
1059
+ wf.meta[k] = { x: v.x, y: v.y, w: v.w, h: v.h, outVal: v.outVal };
1060
+ }
1061
+ }
1062
+ persistTabs();
1063
+ }
1064
+ function persistTabs() {
1065
+ const lite = workflows.map(w => ({ id: w.id, name: w.name, state: w.state, meta: w.meta }));
1066
+ localStorage.setItem('nodeWfTabs', JSON.stringify({ workflows: lite, counter: wfCounter }));
1067
+ }
1068
+ function loadCurrentWf() {
1069
+ const wf = workflows.find(w => w.id === activeWfId);
1070
+ if (!wf || !wf.state) { updHint(); return; }
1071
+ let state;
1072
+ try { state = typeof wf.state === 'string' ? JSON.parse(wf.state) : wf.state; } catch(e) { console.error('Failed to parse state', e); return; }
1073
+ nodeMeta = {};
1074
+ if (wf.meta) { for (const [k, v] of Object.entries(wf.meta)) { nodeMeta[k] = { ...v, imgB64: null, outB64: null, lastHash: null }; } }
1075
+ py(`graph.load_state(${JSON.stringify(state)})`);
1076
+ for (const [nid, nState] of Object.entries(state.nodes)) {
1077
+ const m = nodeMeta[nid] || { x: Math.random()*300+50, y: Math.random()*200+50, w: 190, h: 120 };
1078
+ nodeMeta[nid] = m;
1079
+ const el = document.createElement('div');
1080
+ el.className = 'node' + (nState.disabled ? ' disabled' : '');
1081
+ el.dataset.nid = nid;
1082
+ el.style.left = m.x + 'px'; el.style.top = m.y + 'px';
1083
+ if (m.w) el.style.width = m.w + 'px';
1084
+ const td = pyJson(`json.dumps(NODE_DEFS["${nState.type}"])`);
1085
+ const hdr = document.createElement('div'); hdr.className = 'nhdr'; hdr.style.background = td.color;
1086
+ hdr.innerHTML = `<span class="nhdr-title">${td.name}</span><span class="ndel" title="Delete">&times;</span>`;
1087
+ el.appendChild(hdr);
1088
+ const body = document.createElement('div'); body.className = 'nbody'; el.appendChild(body);
1089
+ const res = document.createElement('div'); res.className = 'nres'; el.appendChild(res);
1090
+ wfInner.appendChild(el);
1091
+ renderNodeBody(parseInt(nid));
1092
+ }
1093
+ const nids = Object.keys(state.nodes).map(Number);
1094
+ if (nids.length) nextNid = Math.max(...nids) + 1;
1095
+ updPortStyles(); drawConns(); updHint(); drawMinimap();
1096
+ }
1097
+ /* ═══════════════════════════════════════════
1098
+ LIBRARY SAVE/LOAD
1099
+ ═══════════════════════════════════════════ */
1100
+ function loadWfLibrary() {
1101
+ const list = $('#wfList');
1102
+ const libs = JSON.parse(localStorage.getItem('nodeWfLibrary') || '[]');
1103
+ if (!libs.length) { list.innerHTML = '<div class="sb-empty">No saved workflows yet</div>'; return; }
1104
+ list.innerHTML = '';
1105
+ libs.forEach((lib, i) => {
1106
+ const div = document.createElement('div'); div.className = 'sb-wf-item';
1107
+ div.innerHTML = `<span>${lib.name}</span><span class="sb-wf-del">&times;</span>`;
1108
+ div.querySelector('span:first-child').addEventListener('click', () => loadFromLibrary(i));
1109
+ div.querySelector('.sb-wf-del').addEventListener('click', e => { e.stopPropagation(); removeFromLibrary(i); });
1110
+ list.appendChild(div);
1111
+ });
1112
+ }
1113
+ function saveToLibrary() {
1114
+ saveCurrentWf();
1115
+ const wf = workflows.find(w => w.id === activeWfId);
1116
+ if (!wf || !wf.state) { toast('Nothing to save', 'err'); return; }
1117
+ const name = prompt('Save workflow as:', wf.name);
1118
+ if (!name) return;
1119
+ const libs = JSON.parse(localStorage.getItem('nodeWfLibrary') || '[]');
1120
+ const liteMeta = {};
1121
+ for (const [k, v] of Object.entries(nodeMeta)) liteMeta[k] = { x: v.x, y: v.y, w: v.w, h: v.h };
1122
+ const stateObj = typeof wf.state === 'string' ? JSON.parse(wf.state) : wf.state;
1123
+ libs.push({ name, state: stateObj, meta: liteMeta });
1124
+ localStorage.setItem('nodeWfLibrary', JSON.stringify(libs));
1125
+ loadWfLibrary(); toast('Saved to library', 'ok');
1126
+ }
1127
+ function loadFromLibrary(idx) {
1128
+ const libs = JSON.parse(localStorage.getItem('nodeWfLibrary') || '[]');
1129
+ if (!libs[idx]) return;
1130
+ addTab();
1131
+ const wf = workflows.find(w => w.id === activeWfId);
1132
+ wf.name = libs[idx].name;
1133
+ wf.state = JSON.stringify(libs[idx].state);
1134
+ wf.meta = libs[idx].meta || {};
1135
+ loadCurrentWf(); renderTabs(); toast('Workflow loaded', 'inf');
1136
+ }
1137
+ function removeFromLibrary(idx) {
1138
+ let libs = JSON.parse(localStorage.getItem('nodeWfLibrary') || '[]');
1139
+ libs.splice(idx, 1);
1140
+ localStorage.setItem('nodeWfLibrary', JSON.stringify(libs));
1141
+ loadWfLibrary();
1142
+ }
1143
+ $('#btnSave').addEventListener('click', saveToLibrary);
1144
+ $('#btnClear').addEventListener('click', () => { clearWfDOM(); py('graph.clear()'); updHint(); toast('Canvas cleared', 'inf'); });
1145
+ /* ══���════════════════════════════════════════
1146
+ SETTINGS
1147
+ ═══════════════════════════════════════════ */
1148
+ $('#btnSettings').addEventListener('click', e => { e.stopPropagation(); $('#settingsPanel').classList.toggle('open'); });
1149
+ document.addEventListener('click', e => { if (!e.target.closest('#settingsPanel') && !e.target.closest('#btnSettings')) $('#settingsPanel').classList.remove('open'); });
1150
+ $('#gridSizeSel').addEventListener('change', e => { gridSize = parseInt(e.target.value); updateGrid(); });
1151
+ /* ═══════════════════════════════════════════
1152
+ WORKFLOW EXECUTION
1153
+ ═══════════════════════════════════════════ */
1154
+ async function runWorkflow() {
1155
+ if (!pyReady) { toast('Environment not ready', 'err'); return; }
1156
+ const nodeCount = pyJson('len(graph.nodes)');
1157
+ if (!nodeCount) { toast('Add some nodes first', 'err'); return; }
1158
+ const btn = $('#btnRun');
1159
+ btn.disabled = true; btn.textContent = 'Running...';
1160
+ const forceRun = $('#forceRunCheck').checked;
1161
+ wfInner.querySelectorAll('.node').forEach(n => n.classList.remove('done','running','err'));
1162
+ const order = pyJson('json.dumps(graph.topological_sort())');
1163
+ if (!order) { toast('Cycle detected!', 'err'); btn.disabled = false; btn.textContent = 'Run'; return; }
1164
+ const edges = pyJson('json.dumps(graph.edges)');
1165
+ const inMap = {};
1166
+ order.forEach(nid => inMap[nid] = []);
1167
+ if (edges) edges.forEach(e => inMap[e.tn].push(e));
1168
+ let hadError = false;
1169
+ for (const nid of order) {
1170
+ const nd = nodeMeta[nid]; if (!nd) continue;
1171
+ const state = pyJson(`json.dumps(graph.nodes[${nid}])`);
1172
+ const el = wfInner.querySelector(`.node[data-nid="${nid}"]`);
1173
+ const isValue = pyJson(`graph.is_value_node(${nid})`);
1174
+ const td = pyJson(`json.dumps(NODE_DEFS["${state.type}"])`);
1175
+ if (el) { el.classList.remove('done','running','err'); el.classList.add('running'); }
1176
+ try {
1177
+ let inputB64 = null;
1178
+ const baseIns = pyJson(`json.dumps(NODE_DEFS["${state.type}"]["ins"])`);
1179
+ if (inMap[nid]) {
1180
+ inMap[nid].forEach(conn => {
1181
+ const src = nodeMeta[conn.fn]; if (!src) return;
1182
+ if (conn.tp === 0 && baseIns.length > 0 && baseIns[0] === 'image') {
1183
+ inputB64 = src.outB64;
1184
+ }
1185
+ if (conn.tp >= baseIns.length && state.promoted) {
1186
+ const pId = state.promoted[conn.tp - baseIns.length];
1187
+ if (pId && src.outVal !== undefined && src.outVal !== null) {
1188
+ state.params[pId] = src.outVal;
1189
+ }
1190
+ }
1191
+ });
1192
+ }
1193
+ const currentHash = hashStr((inputB64||'').slice(0,80) + JSON.stringify(state.params) + state.type + String(state.disabled));
1194
+ if (state.disabled) {
1195
+ nd.outB64 = inputB64;
1196
+ nd.outVal = isValue ? state.params.value : undefined;
1197
+ nd.lastHash = currentHash;
1198
+ } else if (!forceRun && nd.lastHash === currentHash && (nd.outB64 || isValue)) {
1199
+ // Cache hit
1200
+ } else {
1201
+ if (isValue) {
1202
+ nd.outVal = state.params.value;
1203
+ nd.outB64 = null;
1204
+ } else if (state.type === 'image_input') {
1205
+ nd.outB64 = nd.imgB64 || null;
1206
+ nd.outVal = undefined;
1207
+ } else if (state.type === 'image_output') {
1208
+ nd.outB64 = inputB64;
1209
+ nd.outVal = undefined;
1210
+ } else {
1211
+ if (!inputB64) {
1212
+ nd.outB64 = null;
1213
+ } else {
1214
+ pyodide.globals.set('_in_b64', inputB64);
1215
+ pyodide.globals.set('_op', state.type);
1216
+ pyodide.globals.set('_pjson', JSON.stringify(state.params));
1217
+ py('output_b64 = process_image(_in_b64, _op, _pjson)');
1218
+ nd.outB64 = pyodide.globals.get('output_b64');
1219
+ }
1220
+ nd.outVal = undefined;
1221
+ }
1222
+ nd.lastHash = currentHash;
1223
+ }
1224
+ setPreview(nid, nd.outB64, nd.outVal);
1225
+ if (el) { el.classList.remove('running'); el.classList.add('done'); }
1226
+ } catch (err) {
1227
+ console.error('Node error:', state.type, err);
1228
+ if (el) { el.classList.remove('running'); el.classList.add('err'); }
1229
+ toast('Error in ' + (td.name || state.type) + ': ' + (err.message || String(err)).slice(0, 80), 'err');
1230
+ hadError = true; break;
1231
+ }
1232
+ await new Promise(r => setTimeout(r, 15));
1233
+ }
1234
+ if (!hadError) toast('Workflow complete!', 'ok');
1235
+ btn.disabled = false; btn.textContent = 'Run';
1236
+ }
1237
+ $('#btnRun').addEventListener('click', runWorkflow);
1238
+ /* ═══════════════════════════════════════════
1239
+ KEYBOARD SHORTCUTS
1240
+ ═══════════════════════════════════════════ */
1241
+ document.addEventListener('keydown', e => {
1242
+ if (e.target.contentEditable === 'true' || e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT' || e.target.tagName === 'TEXTAREA' || e.target.closest('.CodeMirror')) return;
1243
+ if (e.key === 'Delete' || e.key === 'Backspace') {
1244
+ const sel = wfInner.querySelectorAll('.node.sel');
1245
+ if (sel.length) { sel.forEach(n => delNode(parseInt(n.dataset.nid))); e.preventDefault(); }
1246
+ }
1247
+ if (e.key === 'Escape') {
1248
+ closeCtxMenu(); closeSearchMenu();
1249
+ $('#settingsPanel').classList.remove('open');
1250
+ }
1251
+ if (e.key === ' ') {
1252
+ if (e.target === wfC || wfC.contains(e.target)) { e.preventDefault(); fitAll(); }
1253
+ }
1254
+ });
1255
+ /* ═══════════════════════════════════════════
1256
+ PYODIDE INIT
1257
+ ═══════════════════════════════════════════ */
1258
+ async function initPyodide() {
1259
+ const ldMsg = $('#ldMsg'), ldSub = $('#ldSub');
1260
+ try {
1261
+ ldMsg.textContent = 'Loading Pyodide runtime...';
1262
+ pyodide = await loadPyodide();
1263
+ ldSub.textContent = 'Loading numpy...';
1264
+ await pyodide.loadPackage('numpy');
1265
+ ldSub.textContent = 'Loading Pillow...';
1266
+ await pyodide.loadPackage('pillow');
1267
+ ldSub.textContent = 'Loading scikit-image...';
1268
+ try {
1269
+ await pyodide.loadPackage('scikit-image');
1270
+ } catch(e) {
1271
+ ldSub.textContent = 'Installing scikit-image via micropip...';
1272
+ await pyodide.loadPackage('micropip');
1273
+ const mp = pyodide.pyimport('micropip');
1274
+ await mp.install('scikit-image');
1275
+ }
1276
+ ldSub.textContent = 'Initializing engine...';
1277
+ pyodide.runPython(PY_GRAPH);
1278
+ pyodide.runPython(PY_PROCESSOR);
1279
+ pyReady = true;
1280
+ $('#btnRun').disabled = false;
1281
+ $('#sDot').className = 'sdot ok';
1282
+ $('#sTxt').textContent = 'Ready';
1283
+ ldMsg.textContent = 'Ready!';
1284
+ setTimeout(() => $('#loader').classList.add('done'), 300);
1285
+ buildSidebar();
1286
+ initTabs();
1287
+ toast('Ready! Drag nodes or double-click canvas to begin.', 'ok');
1288
+ } catch(e) {
1289
+ console.error('Init error:', e);
1290
+ ldMsg.textContent = 'Failed to load';
1291
+ ldSub.textContent = (e.message || String(e)).slice(0, 300);
1292
+ $('.spinner').style.display = 'none';
1293
+ }
1294
+ }
1295
+ /* ═══════════════════════════════════════════
1296
+ BOOTSTRAP
1297
+ ═══════════════════════════════════════════ */
1298
+ resizeCvs();
1299
+ initPyodide();
1300
+ /* ═══════════════════════════════════════════
1301
+ TOUCH SUPPORT
1302
+ ═══════════════════════════════════════════ */
1303
+ let lastTouchDist = null;
1304
+ wfC.addEventListener('touchstart', e => {
1305
+ if (e.touches.length === 2) {
1306
+ const dx = e.touches[0].clientX - e.touches[1].clientX;
1307
+ const dy = e.touches[0].clientY - e.touches[1].clientY;
1308
+ lastTouchDist = Math.sqrt(dx*dx + dy*dy);
1309
+ }
1310
+ }, { passive: true });
1311
+ wfC.addEventListener('touchmove', e => {
1312
+ if (e.touches.length === 2 && lastTouchDist !== null) {
1313
+ e.preventDefault();
1314
+ const dx = e.touches[0].clientX - e.touches[1].clientX;
1315
+ const dy = e.touches[0].clientY - e.touches[1].clientY;
1316
+ const dist = Math.sqrt(dx*dx + dy*dy);
1317
+ const factor = dist / lastTouchDist;
1318
+ const nz = Math.max(0.1, Math.min(4, zoom * factor));
1319
+ const cx = (e.touches[0].clientX + e.touches[1].clientX) / 2;
1320
+ const cy = (e.touches[0].clientY + e.touches[1].clientY) / 2;
1321
+ const r = wfC.getBoundingClientRect();
1322
+ const mx = cx - r.left, my = cy - r.top;
1323
+ panX = mx - (mx - panX) * (nz / zoom);
1324
+ panY = my - (my - panY) * (nz / zoom);
1325
+ zoom = nz;
1326
+ applyTransform();
1327
+ lastTouchDist = dist;
1328
+ }
1329
+ }, { passive: false });
1330
+ wfC.addEventListener('touchend', () => { lastTouchDist = null; }, { passive: true });
1331
+ </script>
1332
+ </body>
1333
  </html>