splime 0.1.2__py3-none-any.whl
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.
- spl/__init__.py +14 -0
- spl/client.py +1364 -0
- spl/core/__init__.py +23 -0
- spl/core/common.py +350 -0
- spl/core/entities/__init__.py +0 -0
- spl/core/entities/adapter.py +210 -0
- spl/core/entities/artifact.py +141 -0
- spl/core/entities/control.py +45 -0
- spl/core/entities/distribution.py +65 -0
- spl/core/entities/function.py +254 -0
- spl/core/entities/local_function.py +286 -0
- spl/core/entities/misc.py +14 -0
- spl/core/entities/module.py +88 -0
- spl/core/entities/node.py +286 -0
- spl/core/entities/node_function.py +79 -0
- spl/core/entities/node_remote.py +295 -0
- spl/core/entities/pipeline.py +436 -0
- spl/core/entities/scalar.py +55 -0
- spl/core/ir/__init__.py +0 -0
- spl/core/ir/common.py +34 -0
- spl/core/ir/parse.py +79 -0
- spl/core/ir/unparse.py +29 -0
- spl/core/ir/utils.py +163 -0
- spl/daemon/__init__.py +23 -0
- spl/daemon/__main__.py +11 -0
- spl/daemon/cli.py +582 -0
- spl/daemon/client.py +43 -0
- spl/daemon/docker_environment.py +329 -0
- spl/daemon/docker_pool.py +516 -0
- spl/daemon/environment.py +228 -0
- spl/daemon/environment_base.py +479 -0
- spl/daemon/heartbeat_service.py +119 -0
- spl/daemon/metadata.py +427 -0
- spl/daemon/remote_client.py +457 -0
- spl/daemon/repositories/__init__.py +17 -0
- spl/daemon/repositories/env.py +323 -0
- spl/daemon/repositories/library.py +181 -0
- spl/daemon/repositories/object.py +997 -0
- spl/daemon/repositories/run.py +279 -0
- spl/daemon/repositories/server_connection.py +657 -0
- spl/daemon/repositories/sync_event.py +129 -0
- spl/daemon/routes/__init__.py +1 -0
- spl/daemon/routes/_helpers.py +147 -0
- spl/daemon/routes/artifacts.py +77 -0
- spl/daemon/routes/diagnostics.py +114 -0
- spl/daemon/routes/envs.py +82 -0
- spl/daemon/routes/libraries.py +129 -0
- spl/daemon/routes/objects.py +174 -0
- spl/daemon/routes/remote.py +56 -0
- spl/daemon/routes/runs.py +96 -0
- spl/daemon/routes/server_connections.py +86 -0
- spl/daemon/runtime_backend.py +368 -0
- spl/daemon/runtime_config.py +133 -0
- spl/daemon/runtime_dependencies.py +459 -0
- spl/daemon/secret_store.py +187 -0
- spl/daemon/server.py +2224 -0
- spl/daemon/server_connection.py +267 -0
- spl/daemon/services/__init__.py +1 -0
- spl/daemon/services/sync.py +76 -0
- spl/daemon/signature.py +376 -0
- spl/daemon/storage_base.py +542 -0
- spl/daemon/store.py +436 -0
- spl/daemon/worker.py +526 -0
- spl/daemon_client.py +945 -0
- spl/pipeline_widget.py +1452 -0
- spl/py.typed +0 -0
- spl/server_client.py +787 -0
- splime-0.1.2.dist-info/METADATA +189 -0
- splime-0.1.2.dist-info/RECORD +74 -0
- splime-0.1.2.dist-info/WHEEL +5 -0
- splime-0.1.2.dist-info/entry_points.txt +2 -0
- splime-0.1.2.dist-info/licenses/LICENSE +201 -0
- splime-0.1.2.dist-info/licenses/NOTICE +8 -0
- splime-0.1.2.dist-info/top_level.txt +1 -0
spl/pipeline_widget.py
ADDED
|
@@ -0,0 +1,1452 @@
|
|
|
1
|
+
"""Notebook HTML widget for visualizing SPL pipelines.
|
|
2
|
+
|
|
3
|
+
The Console owns the primary pipeline graph implementation. This module keeps
|
|
4
|
+
an intentionally duplicated, dependency-free renderer for Jupyter notebooks so
|
|
5
|
+
``spl-framework`` users can inspect live or published pipelines without running
|
|
6
|
+
the frontend app.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import html
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Any
|
|
16
|
+
from uuid import uuid4
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
NODE_WIDTH = 292
|
|
20
|
+
PORT_ROW_HEIGHT = 28
|
|
21
|
+
PORT_Y_OFFSET = 76
|
|
22
|
+
LAYER_GAP = 166
|
|
23
|
+
ROW_GAP = 72
|
|
24
|
+
STAGE_PADDING = 72
|
|
25
|
+
MIN_SCALE = 0.52
|
|
26
|
+
MAX_SCALE = 1.8
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class PipelineGraphWidget:
|
|
31
|
+
"""Rich notebook display object for one pipeline graph."""
|
|
32
|
+
|
|
33
|
+
decomposition: dict[str, Any]
|
|
34
|
+
object_info: dict[str, Any] = field(default_factory=dict)
|
|
35
|
+
height: int = 560
|
|
36
|
+
theme: str = "dark"
|
|
37
|
+
dom_id: str = field(default_factory=lambda: f"spl-pipeline-{uuid4().hex}")
|
|
38
|
+
|
|
39
|
+
def _repr_html_(self) -> str:
|
|
40
|
+
"""Return the HTML representation used by Jupyter rich display."""
|
|
41
|
+
|
|
42
|
+
return render_pipeline_graph_html(
|
|
43
|
+
self.decomposition,
|
|
44
|
+
self.object_info,
|
|
45
|
+
height=self.height,
|
|
46
|
+
theme=self.theme,
|
|
47
|
+
dom_id=self.dom_id,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def html(self) -> str:
|
|
52
|
+
"""Return the widget HTML for callers that want to embed it manually."""
|
|
53
|
+
|
|
54
|
+
return self._repr_html_()
|
|
55
|
+
|
|
56
|
+
def display(self) -> "PipelineGraphWidget":
|
|
57
|
+
"""Display the widget immediately when IPython is available."""
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
from IPython.display import display
|
|
61
|
+
except Exception:
|
|
62
|
+
return self
|
|
63
|
+
display(self)
|
|
64
|
+
return self
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def pipeline_to_decomposition(pipeline: Any) -> dict[str, Any]:
|
|
68
|
+
"""Convert a live ``spl.core`` Pipeline into the daemon decomposition shape."""
|
|
69
|
+
|
|
70
|
+
from spl.core.entities.node import NodeInputRef, NodeOutputRef
|
|
71
|
+
from spl.core.entities.node_function import NodeFunction
|
|
72
|
+
from spl.core.entities.node_remote import NodeRemote
|
|
73
|
+
from spl.core.entities.scalar import Scalar
|
|
74
|
+
|
|
75
|
+
nodes = []
|
|
76
|
+
functions = []
|
|
77
|
+
|
|
78
|
+
for node in sorted(pipeline.nodes, key=lambda item: str(item.uuid)):
|
|
79
|
+
node_id = str(node.uuid)
|
|
80
|
+
inputs = [_input_port_to_dict(port) for port in getattr(node, "inputs", [])]
|
|
81
|
+
outputs = [_output_port_to_dict(port) for port in getattr(node, "outputs", [])]
|
|
82
|
+
if isinstance(node, NodeFunction):
|
|
83
|
+
function_name = getattr(node.func, "__name__", node_id)
|
|
84
|
+
payload = {
|
|
85
|
+
"node_id": node_id,
|
|
86
|
+
"id": node_id,
|
|
87
|
+
"kind": "function",
|
|
88
|
+
"function": function_name,
|
|
89
|
+
"name": function_name,
|
|
90
|
+
"inputs": inputs,
|
|
91
|
+
"outputs": outputs,
|
|
92
|
+
}
|
|
93
|
+
functions.append(
|
|
94
|
+
{
|
|
95
|
+
"kind": "function",
|
|
96
|
+
"role": "pipeline_component",
|
|
97
|
+
"node_id": node_id,
|
|
98
|
+
"name": function_name,
|
|
99
|
+
"inputs": inputs,
|
|
100
|
+
"outputs": outputs,
|
|
101
|
+
}
|
|
102
|
+
)
|
|
103
|
+
elif isinstance(node, NodeRemote):
|
|
104
|
+
remote = {
|
|
105
|
+
"url": node.url,
|
|
106
|
+
"name": node.name,
|
|
107
|
+
"version": node.version,
|
|
108
|
+
}
|
|
109
|
+
for attr in ("owner_id", "library", "target_machine"):
|
|
110
|
+
value = getattr(node, attr, None)
|
|
111
|
+
if value is not None:
|
|
112
|
+
remote[attr] = value
|
|
113
|
+
payload = {
|
|
114
|
+
"node_id": node_id,
|
|
115
|
+
"id": node_id,
|
|
116
|
+
"kind": "remote",
|
|
117
|
+
"name": node.name,
|
|
118
|
+
"remote": remote,
|
|
119
|
+
"inputs": inputs,
|
|
120
|
+
"outputs": outputs,
|
|
121
|
+
}
|
|
122
|
+
else:
|
|
123
|
+
payload = {
|
|
124
|
+
"node_id": node_id,
|
|
125
|
+
"id": node_id,
|
|
126
|
+
"kind": type(node).__name__,
|
|
127
|
+
"name": repr(node),
|
|
128
|
+
"inputs": inputs,
|
|
129
|
+
"outputs": outputs,
|
|
130
|
+
}
|
|
131
|
+
nodes.append(payload)
|
|
132
|
+
|
|
133
|
+
links = []
|
|
134
|
+
for node_input_ref, value in sorted(pipeline.links, key=_link_sort_key):
|
|
135
|
+
if not isinstance(node_input_ref, NodeInputRef):
|
|
136
|
+
continue
|
|
137
|
+
target = {
|
|
138
|
+
"node_id": str(node_input_ref.node.uuid),
|
|
139
|
+
"port": node_input_ref.port.name,
|
|
140
|
+
}
|
|
141
|
+
if isinstance(value, NodeOutputRef):
|
|
142
|
+
source: dict[str, Any] = {
|
|
143
|
+
"kind": "node_output",
|
|
144
|
+
"node_id": str(value.node.uuid),
|
|
145
|
+
"port": value.port.name,
|
|
146
|
+
}
|
|
147
|
+
elif isinstance(value, Scalar):
|
|
148
|
+
source = {
|
|
149
|
+
"kind": "scalar",
|
|
150
|
+
"value": _json_safe_value(value.value),
|
|
151
|
+
}
|
|
152
|
+
else:
|
|
153
|
+
source = {
|
|
154
|
+
"kind": type(value).__name__,
|
|
155
|
+
"value": repr(value),
|
|
156
|
+
}
|
|
157
|
+
links.append(
|
|
158
|
+
{
|
|
159
|
+
"target_node_id": target["node_id"],
|
|
160
|
+
"target_port": target["port"],
|
|
161
|
+
"source_kind": source["kind"],
|
|
162
|
+
"source_node_id": source.get("node_id"),
|
|
163
|
+
"source_port": source.get("port"),
|
|
164
|
+
"scalar_json": source.get("value"),
|
|
165
|
+
"source": source,
|
|
166
|
+
"target": target,
|
|
167
|
+
"raw": {
|
|
168
|
+
"from": target,
|
|
169
|
+
"to": source,
|
|
170
|
+
},
|
|
171
|
+
}
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
"functions": functions,
|
|
176
|
+
"nodes": nodes,
|
|
177
|
+
"links": links,
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def render_pipeline_graph_html(
|
|
182
|
+
decomposition: dict[str, Any],
|
|
183
|
+
object_info: dict[str, Any] | None = None,
|
|
184
|
+
*,
|
|
185
|
+
height: int = 560,
|
|
186
|
+
theme: str = "dark",
|
|
187
|
+
dom_id: str | None = None,
|
|
188
|
+
) -> str:
|
|
189
|
+
"""Render a standalone pipeline graph widget as HTML."""
|
|
190
|
+
|
|
191
|
+
object_info = object_info or {}
|
|
192
|
+
dom_id = dom_id or f"spl-pipeline-{uuid4().hex}"
|
|
193
|
+
model = create_pipeline_graph_model(decomposition, object_info)
|
|
194
|
+
layout_pipeline_graph(model)
|
|
195
|
+
selected_node_id = _first_selectable_node_id(model)
|
|
196
|
+
model["domId"] = dom_id
|
|
197
|
+
model["selectedNodeId"] = selected_node_id
|
|
198
|
+
model_json = _json_for_script(model)
|
|
199
|
+
theme_attr = html.escape(theme or "dark", quote=True)
|
|
200
|
+
title = _escape(model["title"])
|
|
201
|
+
stats = model["stats"]
|
|
202
|
+
stats_label = (
|
|
203
|
+
f"{stats['functionNodes']} functions / "
|
|
204
|
+
f"{len(model['links'])} links / {stats['portCount']} ports"
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
return f"""
|
|
208
|
+
<div id="{html.escape(dom_id, quote=True)}" class="pipeline-graph-shell spl-pipeline-widget" data-spl-pipeline-widget data-theme="{theme_attr}">
|
|
209
|
+
<style>{_widget_css(dom_id, height)}</style>
|
|
210
|
+
<script type="application/json" data-spl-pipeline-model>{model_json}</script>
|
|
211
|
+
<div class="pipeline-graph-topbar">
|
|
212
|
+
<div class="pipeline-graph-title">
|
|
213
|
+
<span class="panel-label">Node graph</span>
|
|
214
|
+
<strong>{title}</strong>
|
|
215
|
+
<small>{_escape(stats_label)}</small>
|
|
216
|
+
</div>
|
|
217
|
+
<div class="pipeline-graph-tools" role="toolbar" aria-label="Pipeline graph controls">
|
|
218
|
+
{_graph_tool("zoom-out", "Zoom out", "zoomOut")}
|
|
219
|
+
{_graph_tool("target", "Fit view", "fit")}
|
|
220
|
+
{_graph_tool("zoom-in", "Zoom in", "zoomIn")}
|
|
221
|
+
{_graph_tool("reset", "Reset view", "reset")}
|
|
222
|
+
{_graph_tool("fullscreen", "Enter fullscreen", "fullscreen")}
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
<div class="pipeline-graph-workbench">
|
|
226
|
+
<aside class="pipeline-graph-sidebar pipeline-graph-outliner">
|
|
227
|
+
<div class="pipeline-sidebar-head">
|
|
228
|
+
<span class="panel-label">Outliner</span>
|
|
229
|
+
<strong>{len(model["nodes"])} nodes</strong>
|
|
230
|
+
</div>
|
|
231
|
+
<div class="pipeline-outliner-list">
|
|
232
|
+
{_render_outliner(model, selected_node_id)}
|
|
233
|
+
</div>
|
|
234
|
+
</aside>
|
|
235
|
+
<div class="pipeline-graph-viewport" data-pipeline-viewport tabindex="0" aria-label="Pipeline graph canvas">
|
|
236
|
+
<div class="pipeline-graph-stage" data-pipeline-stage style="width:{model["stageWidth"]}px;height:{model["stageHeight"]}px">
|
|
237
|
+
{_render_edge_svg(model)}
|
|
238
|
+
{"".join(_render_node(node, selected_node_id) for node in model["nodes"])}
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
<aside class="pipeline-graph-sidebar pipeline-graph-inspector" data-pipeline-inspector>
|
|
242
|
+
{_render_inspector(model, selected_node_id)}
|
|
243
|
+
</aside>
|
|
244
|
+
</div>
|
|
245
|
+
<script>{_widget_js(dom_id)}</script>
|
|
246
|
+
</div>
|
|
247
|
+
"""
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def create_pipeline_graph_model(
|
|
251
|
+
decomposition: dict[str, Any],
|
|
252
|
+
object_info: dict[str, Any] | None = None,
|
|
253
|
+
) -> dict[str, Any]:
|
|
254
|
+
"""Create the graph model consumed by the notebook renderer."""
|
|
255
|
+
|
|
256
|
+
object_info = object_info or {}
|
|
257
|
+
links = _ensure_list(decomposition.get("links"))
|
|
258
|
+
raw_nodes = _ensure_list(decomposition.get("nodes"))
|
|
259
|
+
functions = _ensure_list(decomposition.get("functions"))
|
|
260
|
+
used_node_ids: set[str] = set()
|
|
261
|
+
used_link_ids: set[str] = set()
|
|
262
|
+
nodes_by_raw_id: dict[str, dict[str, Any]] = {}
|
|
263
|
+
nodes_by_function: dict[str, dict[str, Any]] = {}
|
|
264
|
+
|
|
265
|
+
nodes = []
|
|
266
|
+
for index, raw_node in enumerate(raw_nodes):
|
|
267
|
+
raw_id = _node_id_for(raw_node, index)
|
|
268
|
+
function_name = _function_name_for(raw_node)
|
|
269
|
+
function_row = _matching_function(functions, raw_id, function_name)
|
|
270
|
+
node_id = _unique_id(_slug_for(raw_id, f"node-{index + 1}"), used_node_ids)
|
|
271
|
+
inputs = _merge_ports(
|
|
272
|
+
raw_node.get("inputs") or function_row.get("inputs"),
|
|
273
|
+
_link_ports_for_node(links, raw_id, "target"),
|
|
274
|
+
"input",
|
|
275
|
+
)
|
|
276
|
+
outputs = _merge_ports(
|
|
277
|
+
raw_node.get("outputs") or function_row.get("outputs"),
|
|
278
|
+
_link_ports_for_node(links, raw_id, "source"),
|
|
279
|
+
"output",
|
|
280
|
+
)
|
|
281
|
+
kind = str(raw_node.get("kind") or raw_node.get("type") or "function")
|
|
282
|
+
label = function_name
|
|
283
|
+
model_node = {
|
|
284
|
+
"id": node_id,
|
|
285
|
+
"rawId": raw_id,
|
|
286
|
+
"kind": "function",
|
|
287
|
+
"nodeKind": kind,
|
|
288
|
+
"label": label,
|
|
289
|
+
"subtitle": raw_id,
|
|
290
|
+
"version": raw_node.get("version") or raw_node.get("version_id") or function_row.get("version") or "",
|
|
291
|
+
"inputs": inputs,
|
|
292
|
+
"outputs": outputs,
|
|
293
|
+
"width": NODE_WIDTH,
|
|
294
|
+
"height": _node_height(inputs, outputs),
|
|
295
|
+
}
|
|
296
|
+
nodes_by_raw_id[raw_id] = model_node
|
|
297
|
+
nodes_by_function[label] = model_node
|
|
298
|
+
nodes.append(model_node)
|
|
299
|
+
|
|
300
|
+
for index, item in enumerate(functions):
|
|
301
|
+
function_name = str(item.get("name") or item.get("function") or item.get("function_name") or f"function_{index + 1}")
|
|
302
|
+
if function_name in nodes_by_function:
|
|
303
|
+
continue
|
|
304
|
+
raw_id = str(item.get("node_id") or item.get("id") or function_name)
|
|
305
|
+
node_id = _unique_id(_slug_for(raw_id, f"function-{index + 1}"), used_node_ids)
|
|
306
|
+
inputs = _merge_ports(item.get("inputs"), [], "input")
|
|
307
|
+
outputs = _merge_ports(item.get("outputs"), [], "output")
|
|
308
|
+
model_node = {
|
|
309
|
+
"id": node_id,
|
|
310
|
+
"rawId": raw_id,
|
|
311
|
+
"kind": "function",
|
|
312
|
+
"nodeKind": str(item.get("kind") or "function"),
|
|
313
|
+
"label": function_name,
|
|
314
|
+
"subtitle": raw_id,
|
|
315
|
+
"version": item.get("version") or item.get("version_id") or "",
|
|
316
|
+
"inputs": inputs,
|
|
317
|
+
"outputs": outputs,
|
|
318
|
+
"width": NODE_WIDTH,
|
|
319
|
+
"height": _node_height(inputs, outputs),
|
|
320
|
+
}
|
|
321
|
+
nodes_by_raw_id[raw_id] = model_node
|
|
322
|
+
nodes_by_function[function_name] = model_node
|
|
323
|
+
nodes.append(model_node)
|
|
324
|
+
|
|
325
|
+
external_nodes: dict[str, dict[str, Any]] = {}
|
|
326
|
+
model_links = []
|
|
327
|
+
for index, link in enumerate(links):
|
|
328
|
+
target_raw_id = _target_node_id(link)
|
|
329
|
+
target_port_name = _target_port(link)
|
|
330
|
+
target_node = nodes_by_raw_id.get(target_raw_id)
|
|
331
|
+
if target_node is None:
|
|
332
|
+
target_node = _create_missing_node(target_raw_id, nodes, used_node_ids, nodes_by_raw_id)
|
|
333
|
+
source_node, source_port_name, source_label = _resolve_source_node(
|
|
334
|
+
link,
|
|
335
|
+
index,
|
|
336
|
+
external_nodes=external_nodes,
|
|
337
|
+
nodes=nodes,
|
|
338
|
+
nodes_by_raw_id=nodes_by_raw_id,
|
|
339
|
+
used_node_ids=used_node_ids,
|
|
340
|
+
)
|
|
341
|
+
_ensure_port(source_node, "output", source_port_name)
|
|
342
|
+
_ensure_port(target_node, "input", target_port_name)
|
|
343
|
+
model_links.append(
|
|
344
|
+
{
|
|
345
|
+
"id": _unique_id(f"link-{index + 1}-{source_node['id']}-{target_node['id']}", used_link_ids),
|
|
346
|
+
"sourceNodeId": source_node["id"],
|
|
347
|
+
"sourceRawNodeId": source_node["rawId"],
|
|
348
|
+
"sourcePort": source_port_name,
|
|
349
|
+
"sourceLabel": _readable_port_label(source_node, source_port_name, source_label),
|
|
350
|
+
"targetNodeId": target_node["id"],
|
|
351
|
+
"targetRawNodeId": target_node["rawId"],
|
|
352
|
+
"targetPort": target_port_name,
|
|
353
|
+
"targetLabel": _readable_port_label(target_node, target_port_name, _target_label_for(link)),
|
|
354
|
+
"label": _link_label(source_port_name, target_port_name),
|
|
355
|
+
}
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
for node in nodes:
|
|
359
|
+
node["inputs"] = _merge_ports(node.get("inputs"), [], "input")
|
|
360
|
+
node["outputs"] = _merge_ports(node.get("outputs"), [], "output")
|
|
361
|
+
node["height"] = _node_height(node["inputs"], node["outputs"])
|
|
362
|
+
|
|
363
|
+
object_name = object_info.get("name") or object_info.get("id") or "pipeline"
|
|
364
|
+
return {
|
|
365
|
+
"id": object_info.get("id") or object_name,
|
|
366
|
+
"title": object_info.get("displayName") or object_info.get("display_name") or object_name or "Pipeline",
|
|
367
|
+
"objectName": object_name,
|
|
368
|
+
"nodes": nodes,
|
|
369
|
+
"links": model_links,
|
|
370
|
+
"stats": {
|
|
371
|
+
"functionNodes": len([node for node in nodes if node.get("kind") != "external"]),
|
|
372
|
+
"externalNodes": len([node for node in nodes if node.get("kind") == "external"]),
|
|
373
|
+
"portCount": sum(len(_visible_ports(node.get("inputs"))) + len(_visible_ports(node.get("outputs"))) for node in nodes),
|
|
374
|
+
},
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def layout_pipeline_graph(model: dict[str, Any]) -> None:
|
|
379
|
+
"""Assign a stable layered layout and edge paths to a graph model."""
|
|
380
|
+
|
|
381
|
+
nodes = model["nodes"]
|
|
382
|
+
node_by_id = {node["id"]: node for node in nodes}
|
|
383
|
+
order = {node["id"]: index for index, node in enumerate(nodes)}
|
|
384
|
+
layers = {node["id"]: 0 for node in nodes}
|
|
385
|
+
for _ in range(max(1, len(nodes))):
|
|
386
|
+
changed = False
|
|
387
|
+
for link in model["links"]:
|
|
388
|
+
source_id = link["sourceNodeId"]
|
|
389
|
+
target_id = link["targetNodeId"]
|
|
390
|
+
next_layer = layers.get(source_id, 0) + 1
|
|
391
|
+
if layers.get(target_id, 0) < next_layer:
|
|
392
|
+
layers[target_id] = next_layer
|
|
393
|
+
changed = True
|
|
394
|
+
if not changed:
|
|
395
|
+
break
|
|
396
|
+
|
|
397
|
+
grouped: dict[int, list[dict[str, Any]]] = {}
|
|
398
|
+
for node in nodes:
|
|
399
|
+
grouped.setdefault(layers.get(node["id"], 0), []).append(node)
|
|
400
|
+
|
|
401
|
+
max_right = STAGE_PADDING + NODE_WIDTH
|
|
402
|
+
max_bottom = STAGE_PADDING + 160
|
|
403
|
+
for layer, layer_nodes in sorted(grouped.items()):
|
|
404
|
+
y = STAGE_PADDING
|
|
405
|
+
for node in sorted(layer_nodes, key=lambda item: order[item["id"]]):
|
|
406
|
+
node["x"] = STAGE_PADDING + layer * (NODE_WIDTH + LAYER_GAP)
|
|
407
|
+
node["y"] = y
|
|
408
|
+
y += node["height"] + ROW_GAP
|
|
409
|
+
max_right = max(max_right, node["x"] + node["width"] + STAGE_PADDING)
|
|
410
|
+
max_bottom = max(max_bottom, node["y"] + node["height"] + STAGE_PADDING)
|
|
411
|
+
|
|
412
|
+
model["stageWidth"] = int(max_right)
|
|
413
|
+
model["stageHeight"] = int(max_bottom)
|
|
414
|
+
|
|
415
|
+
for link in model["links"]:
|
|
416
|
+
source = node_by_id.get(link["sourceNodeId"])
|
|
417
|
+
target = node_by_id.get(link["targetNodeId"])
|
|
418
|
+
if source is None or target is None:
|
|
419
|
+
link["path"] = ""
|
|
420
|
+
continue
|
|
421
|
+
source_index = _port_index(source.get("outputs"), link["sourcePort"])
|
|
422
|
+
target_index = _port_index(target.get("inputs"), link["targetPort"])
|
|
423
|
+
start_x = source["x"] + source["width"]
|
|
424
|
+
start_y = source["y"] + PORT_Y_OFFSET + source_index * PORT_ROW_HEIGHT
|
|
425
|
+
end_x = target["x"]
|
|
426
|
+
end_y = target["y"] + PORT_Y_OFFSET + target_index * PORT_ROW_HEIGHT
|
|
427
|
+
mid_x = max(start_x + 62, round((start_x + end_x) / 2))
|
|
428
|
+
link["path"] = f"M {start_x} {start_y} L {mid_x} {start_y} L {mid_x} {end_y} L {end_x} {end_y}"
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _input_port_to_dict(port: Any) -> dict[str, Any]:
|
|
432
|
+
return {
|
|
433
|
+
"name": str(port.name),
|
|
434
|
+
"type": port.typ_,
|
|
435
|
+
"default": port.default,
|
|
436
|
+
"required": port.default is None,
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _output_port_to_dict(port: Any) -> dict[str, Any]:
|
|
441
|
+
return {
|
|
442
|
+
"name": str(port.name),
|
|
443
|
+
"type": port.typ_,
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _link_sort_key(item: tuple[Any, Any]) -> tuple[str, str, str]:
|
|
448
|
+
node_input_ref, value = item
|
|
449
|
+
source = getattr(getattr(value, "node", None), "uuid", "")
|
|
450
|
+
return (
|
|
451
|
+
str(getattr(getattr(node_input_ref, "node", None), "uuid", "")),
|
|
452
|
+
str(getattr(getattr(node_input_ref, "port", None), "name", "")),
|
|
453
|
+
str(source) or repr(value),
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _json_safe_value(value: Any) -> Any:
|
|
458
|
+
try:
|
|
459
|
+
json.dumps(value)
|
|
460
|
+
except TypeError:
|
|
461
|
+
return repr(value)
|
|
462
|
+
return value
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _ensure_list(value: Any) -> list[Any]:
|
|
466
|
+
return value if isinstance(value, list) else []
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _node_id_for(node: dict[str, Any], index: int) -> str:
|
|
470
|
+
return str(node.get("node_id") or node.get("id") or node.get("name") or f"node-{index + 1}")
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _function_name_for(node: dict[str, Any]) -> str:
|
|
474
|
+
remote = node.get("remote") if isinstance(node.get("remote"), dict) else {}
|
|
475
|
+
return str(
|
|
476
|
+
node.get("function")
|
|
477
|
+
or node.get("function_name")
|
|
478
|
+
or remote.get("name")
|
|
479
|
+
or node.get("object_name")
|
|
480
|
+
or node.get("name")
|
|
481
|
+
or node.get("node_id")
|
|
482
|
+
or "function"
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _matching_function(functions: list[Any], raw_id: str, function_name: str) -> dict[str, Any]:
|
|
487
|
+
for item in functions:
|
|
488
|
+
if not isinstance(item, dict):
|
|
489
|
+
continue
|
|
490
|
+
if (item.get("node_id") or item.get("id")) == raw_id:
|
|
491
|
+
return item
|
|
492
|
+
if (item.get("name") or item.get("function") or item.get("function_name")) == function_name:
|
|
493
|
+
return item
|
|
494
|
+
return {}
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _merge_ports(declared: Any, linked: list[dict[str, Any]], direction: str) -> list[dict[str, Any]]:
|
|
498
|
+
ports = _port_items(declared, direction)
|
|
499
|
+
names = {port["name"] for port in ports}
|
|
500
|
+
for item in linked:
|
|
501
|
+
name = str(item.get("name") or item.get("port") or "default")
|
|
502
|
+
if name not in names:
|
|
503
|
+
ports.append({"name": name, "detail": item.get("detail") or ""})
|
|
504
|
+
names.add(name)
|
|
505
|
+
return ports
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _port_items(value: Any, direction: str) -> list[dict[str, Any]]:
|
|
509
|
+
if isinstance(value, dict):
|
|
510
|
+
iterable = [
|
|
511
|
+
{"name": name, **(detail if isinstance(detail, dict) else {"type": detail})}
|
|
512
|
+
for name, detail in value.items()
|
|
513
|
+
]
|
|
514
|
+
elif isinstance(value, list):
|
|
515
|
+
iterable = [item for item in value if isinstance(item, dict)]
|
|
516
|
+
else:
|
|
517
|
+
iterable = []
|
|
518
|
+
|
|
519
|
+
ports = []
|
|
520
|
+
for index, item in enumerate(iterable):
|
|
521
|
+
name = str(item.get("name") or item.get("port") or ("input" if direction == "input" else "default"))
|
|
522
|
+
detail = item.get("detail")
|
|
523
|
+
if detail is None:
|
|
524
|
+
detail = item.get("type")
|
|
525
|
+
if direction == "input" and item.get("default") is not None:
|
|
526
|
+
default = item["default"]
|
|
527
|
+
detail = f"{detail}, default={default}" if detail else f"default={default}"
|
|
528
|
+
ports.append(
|
|
529
|
+
{
|
|
530
|
+
"name": name,
|
|
531
|
+
"detail": "" if detail is None else str(detail),
|
|
532
|
+
"hidden": bool(item.get("hidden")),
|
|
533
|
+
"order": index,
|
|
534
|
+
}
|
|
535
|
+
)
|
|
536
|
+
return ports
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _link_ports_for_node(links: list[Any], raw_id: str, role: str) -> list[dict[str, Any]]:
|
|
540
|
+
result = []
|
|
541
|
+
for link in links:
|
|
542
|
+
if not isinstance(link, dict):
|
|
543
|
+
continue
|
|
544
|
+
if role == "target" and _target_node_id(link) == raw_id:
|
|
545
|
+
result.append({"name": _target_port(link)})
|
|
546
|
+
if role == "source" and _source_node_id(link) == raw_id:
|
|
547
|
+
result.append({"name": _source_port(link)})
|
|
548
|
+
return result
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _target_node_id(link: dict[str, Any]) -> str:
|
|
552
|
+
target = link.get("target") if isinstance(link.get("target"), dict) else {}
|
|
553
|
+
raw = link.get("raw") if isinstance(link.get("raw"), dict) else {}
|
|
554
|
+
raw_from = raw.get("from") if isinstance(raw.get("from"), dict) else {}
|
|
555
|
+
return str(link.get("target_node_id") or target.get("node_id") or raw_from.get("node_id") or "")
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _target_port(link: dict[str, Any]) -> str:
|
|
559
|
+
target = link.get("target") if isinstance(link.get("target"), dict) else {}
|
|
560
|
+
raw = link.get("raw") if isinstance(link.get("raw"), dict) else {}
|
|
561
|
+
raw_from = raw.get("from") if isinstance(raw.get("from"), dict) else {}
|
|
562
|
+
return str(link.get("target_port") or target.get("port") or raw_from.get("port") or "default")
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _source_node_id(link: dict[str, Any]) -> str:
|
|
566
|
+
source = link.get("source") if isinstance(link.get("source"), dict) else {}
|
|
567
|
+
raw = link.get("raw") if isinstance(link.get("raw"), dict) else {}
|
|
568
|
+
raw_to = raw.get("to") if isinstance(raw.get("to"), dict) else {}
|
|
569
|
+
return str(link.get("source_node_id") or source.get("node_id") or raw_to.get("node_id") or "")
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _source_port(link: dict[str, Any]) -> str:
|
|
573
|
+
source = link.get("source") if isinstance(link.get("source"), dict) else {}
|
|
574
|
+
raw = link.get("raw") if isinstance(link.get("raw"), dict) else {}
|
|
575
|
+
raw_to = raw.get("to") if isinstance(raw.get("to"), dict) else {}
|
|
576
|
+
return str(link.get("source_port") or source.get("port") or raw_to.get("port") or "value")
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _source_kind(link: dict[str, Any]) -> str:
|
|
580
|
+
source = link.get("source") if isinstance(link.get("source"), dict) else {}
|
|
581
|
+
raw = link.get("raw") if isinstance(link.get("raw"), dict) else {}
|
|
582
|
+
raw_to = raw.get("to") if isinstance(raw.get("to"), dict) else {}
|
|
583
|
+
return str(link.get("source_kind") or source.get("kind") or raw_to.get("kind") or "source")
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _resolve_source_node(
|
|
587
|
+
link: dict[str, Any],
|
|
588
|
+
index: int,
|
|
589
|
+
*,
|
|
590
|
+
external_nodes: dict[str, dict[str, Any]],
|
|
591
|
+
nodes: list[dict[str, Any]],
|
|
592
|
+
nodes_by_raw_id: dict[str, dict[str, Any]],
|
|
593
|
+
used_node_ids: set[str],
|
|
594
|
+
) -> tuple[dict[str, Any], str, str]:
|
|
595
|
+
raw_id = _source_node_id(link)
|
|
596
|
+
if raw_id and raw_id in nodes_by_raw_id:
|
|
597
|
+
return nodes_by_raw_id[raw_id], _source_port(link), _source_label_for(link)
|
|
598
|
+
|
|
599
|
+
kind = _source_kind(link)
|
|
600
|
+
value = _source_value(link)
|
|
601
|
+
label = _format_scalar_value(value) if kind in {"scalar", "literal"} else kind
|
|
602
|
+
external_key = f"{kind}:{label}:{index}"
|
|
603
|
+
if external_key not in external_nodes:
|
|
604
|
+
node_id = _unique_id(_slug_for(external_key, f"source-{index + 1}"), used_node_ids)
|
|
605
|
+
node = {
|
|
606
|
+
"id": node_id,
|
|
607
|
+
"rawId": external_key,
|
|
608
|
+
"kind": "external",
|
|
609
|
+
"nodeKind": kind,
|
|
610
|
+
"label": label,
|
|
611
|
+
"subtitle": "bound input",
|
|
612
|
+
"version": "",
|
|
613
|
+
"inputs": [],
|
|
614
|
+
"outputs": [{"name": _source_port(link), "detail": "literal"}],
|
|
615
|
+
"width": 220,
|
|
616
|
+
"height": 128,
|
|
617
|
+
}
|
|
618
|
+
external_nodes[external_key] = node
|
|
619
|
+
nodes.append(node)
|
|
620
|
+
nodes_by_raw_id[external_key] = node
|
|
621
|
+
return external_nodes[external_key], _source_port(link), label
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _source_value(link: dict[str, Any]) -> Any:
|
|
625
|
+
source = link.get("source") if isinstance(link.get("source"), dict) else {}
|
|
626
|
+
raw = link.get("raw") if isinstance(link.get("raw"), dict) else {}
|
|
627
|
+
raw_to = raw.get("to") if isinstance(raw.get("to"), dict) else {}
|
|
628
|
+
if "scalar_json" in link:
|
|
629
|
+
return link.get("scalar_json")
|
|
630
|
+
if "value" in source:
|
|
631
|
+
return source.get("value")
|
|
632
|
+
return raw_to.get("value")
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _create_missing_node(
|
|
636
|
+
raw_id: str,
|
|
637
|
+
nodes: list[dict[str, Any]],
|
|
638
|
+
used_node_ids: set[str],
|
|
639
|
+
nodes_by_raw_id: dict[str, dict[str, Any]],
|
|
640
|
+
) -> dict[str, Any]:
|
|
641
|
+
label = raw_id or "missing node"
|
|
642
|
+
node = {
|
|
643
|
+
"id": _unique_id(_slug_for(label, "missing-node"), used_node_ids),
|
|
644
|
+
"rawId": label,
|
|
645
|
+
"kind": "external",
|
|
646
|
+
"nodeKind": "missing",
|
|
647
|
+
"label": label,
|
|
648
|
+
"subtitle": "missing node",
|
|
649
|
+
"version": "",
|
|
650
|
+
"inputs": [],
|
|
651
|
+
"outputs": [],
|
|
652
|
+
"width": 220,
|
|
653
|
+
"height": 128,
|
|
654
|
+
}
|
|
655
|
+
nodes.append(node)
|
|
656
|
+
nodes_by_raw_id[raw_id] = node
|
|
657
|
+
return node
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _ensure_port(node: dict[str, Any], direction: str, name: str) -> None:
|
|
661
|
+
key = "inputs" if direction == "input" else "outputs"
|
|
662
|
+
ports = node.setdefault(key, [])
|
|
663
|
+
if not any(port.get("name") == name for port in ports):
|
|
664
|
+
ports.append({"name": name, "detail": ""})
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def _readable_port_label(node: dict[str, Any], port_name: str, fallback: str) -> str:
|
|
668
|
+
label = node.get("label") or fallback or node.get("rawId") or "node"
|
|
669
|
+
if node.get("kind") == "external":
|
|
670
|
+
return str(label)
|
|
671
|
+
return f"{label}.{port_name}"
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _source_label_for(link: dict[str, Any]) -> str:
|
|
675
|
+
return _source_port(link)
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _target_label_for(link: dict[str, Any]) -> str:
|
|
679
|
+
return _target_port(link)
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _link_label(source_port: str, target_port: str) -> str:
|
|
683
|
+
if source_port == target_port:
|
|
684
|
+
return target_port
|
|
685
|
+
return f"{source_port} -> {target_port}"
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _node_height(inputs: list[dict[str, Any]], outputs: list[dict[str, Any]]) -> int:
|
|
689
|
+
rows = max(len(_visible_ports(inputs)), len(_visible_ports(outputs)), 1)
|
|
690
|
+
return max(148, 104 + rows * PORT_ROW_HEIGHT)
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _visible_ports(ports: Any) -> list[dict[str, Any]]:
|
|
694
|
+
return [port for port in _ensure_list(ports) if not port.get("hidden")]
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def _port_index(ports: Any, name: str) -> int:
|
|
698
|
+
visible = _visible_ports(ports)
|
|
699
|
+
for index, port in enumerate(visible):
|
|
700
|
+
if port.get("name") == name:
|
|
701
|
+
return index
|
|
702
|
+
return 0
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def _slug_for(value: str, fallback: str) -> str:
|
|
706
|
+
slug = re.sub(r"[^a-zA-Z0-9_-]+", "-", str(value).strip()).strip("-").lower()
|
|
707
|
+
return slug or fallback
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def _unique_id(base: str, used: set[str]) -> str:
|
|
711
|
+
candidate = base
|
|
712
|
+
index = 2
|
|
713
|
+
while candidate in used:
|
|
714
|
+
candidate = f"{base}-{index}"
|
|
715
|
+
index += 1
|
|
716
|
+
used.add(candidate)
|
|
717
|
+
return candidate
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def _first_selectable_node_id(model: dict[str, Any]) -> str:
|
|
721
|
+
for node in model["nodes"]:
|
|
722
|
+
if node.get("kind") != "external":
|
|
723
|
+
return str(node["id"])
|
|
724
|
+
return str(model["nodes"][0]["id"]) if model["nodes"] else ""
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _format_scalar_value(value: Any) -> str:
|
|
728
|
+
if isinstance(value, str):
|
|
729
|
+
return value
|
|
730
|
+
try:
|
|
731
|
+
return json.dumps(value, ensure_ascii=False)
|
|
732
|
+
except TypeError:
|
|
733
|
+
return repr(value)
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def _escape(value: Any) -> str:
|
|
737
|
+
return html.escape("" if value is None else str(value), quote=True)
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def _json_for_script(value: Any) -> str:
|
|
741
|
+
return json.dumps(value, ensure_ascii=False).replace("</", "<\\/")
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def _render_outliner(model: dict[str, Any], selected_node_id: str) -> str:
|
|
745
|
+
if not model["nodes"]:
|
|
746
|
+
return '<div class="pipeline-empty-slot">No nodes</div>'
|
|
747
|
+
return "".join(_render_outliner_node(node, selected_node_id) for node in model["nodes"])
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def _render_outliner_node(node: dict[str, Any], selected_node_id: str) -> str:
|
|
751
|
+
selected = " selected" if node["id"] == selected_node_id else ""
|
|
752
|
+
subtitle = node.get("nodeKind") if node.get("kind") == "external" else node.get("subtitle") or node.get("nodeKind")
|
|
753
|
+
return f"""
|
|
754
|
+
<button class="pipeline-outliner-node{selected}" type="button" data-pipeline-select-node="{_escape(node["id"])}">
|
|
755
|
+
<span>{_escape(node.get("label"))}</span>
|
|
756
|
+
<small>{_escape(subtitle)}</small>
|
|
757
|
+
</button>
|
|
758
|
+
"""
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def _render_edge_svg(model: dict[str, Any]) -> str:
|
|
762
|
+
arrow_id = f"{_slug_for(str(model.get('domId') or model['id']), 'graph')}-pipeline-arrow"
|
|
763
|
+
return f"""
|
|
764
|
+
<svg class="pipeline-graph-edges" viewBox="0 0 {model["stageWidth"]} {model["stageHeight"]}" aria-hidden="true">
|
|
765
|
+
<defs>
|
|
766
|
+
<marker id="{_escape(arrow_id)}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
|
767
|
+
<path d="M 0 0 L 10 5 L 0 10 z"></path>
|
|
768
|
+
</marker>
|
|
769
|
+
</defs>
|
|
770
|
+
{"".join(_render_edge(model, link, index, arrow_id) for index, link in enumerate(model["links"]))}
|
|
771
|
+
</svg>
|
|
772
|
+
"""
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
def _render_edge(model: dict[str, Any], link: dict[str, Any], index: int, arrow_id: str) -> str:
|
|
776
|
+
path_base = _slug_for(str(model.get("domId") or model["id"]), "graph")
|
|
777
|
+
path_id = f"pipeline-edge-path-{path_base}-{index}"
|
|
778
|
+
label = link.get("label") or ""
|
|
779
|
+
return f"""
|
|
780
|
+
<g class="pipeline-graph-edge" data-pipeline-edge-id="{_escape(link["id"])}">
|
|
781
|
+
<path id="{_escape(path_id)}" d="{_escape(link.get("path") or "")}" marker-end="url(#{_escape(arrow_id)})"></path>
|
|
782
|
+
{f'<text dy="-7"><textPath href="#{_escape(path_id)}" startOffset="50%">{_escape(label)}</textPath></text>' if label else ""}
|
|
783
|
+
</g>
|
|
784
|
+
"""
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def _render_node(node: dict[str, Any], selected_node_id: str) -> str:
|
|
788
|
+
selected = " selected" if node["id"] == selected_node_id else ""
|
|
789
|
+
external = " external" if node.get("kind") == "external" else ""
|
|
790
|
+
ports = _render_port_column(node.get("inputs"), "input") + _render_port_column(node.get("outputs"), "output")
|
|
791
|
+
dots = _render_port_dots(node.get("inputs"), "input", 0) + _render_port_dots(node.get("outputs"), "output", node["width"])
|
|
792
|
+
return f"""
|
|
793
|
+
<div class="pipeline-graph-node{external}{selected}" data-pipeline-node-id="{_escape(node["id"])}" tabindex="0" role="button" aria-label="{_escape(node.get("label"))}" style="left:{node["x"]}px;top:{node["y"]}px;width:{node["width"]}px;height:{node["height"]}px">
|
|
794
|
+
<div class="pipeline-graph-node-head">
|
|
795
|
+
<span>{_escape(node.get("subtitle") or node.get("nodeKind"))}</span>
|
|
796
|
+
<strong>{_escape(node.get("label"))}</strong>
|
|
797
|
+
</div>
|
|
798
|
+
<div class="pipeline-graph-node-ports">{ports}</div>
|
|
799
|
+
{dots}
|
|
800
|
+
</div>
|
|
801
|
+
"""
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def _render_port_column(ports: Any, direction: str) -> str:
|
|
805
|
+
visible = _visible_ports(ports)
|
|
806
|
+
if visible:
|
|
807
|
+
body = "".join(
|
|
808
|
+
f"""
|
|
809
|
+
<span class="pipeline-port-row">
|
|
810
|
+
<span>{_escape(port.get("name"))}</span>
|
|
811
|
+
{f'<small>{_escape(port.get("detail"))}</small>' if port.get("detail") else ""}
|
|
812
|
+
</span>
|
|
813
|
+
"""
|
|
814
|
+
for port in visible
|
|
815
|
+
)
|
|
816
|
+
else:
|
|
817
|
+
body = f'<span class="pipeline-port-row muted"><span>{"no inputs" if direction == "input" else "no outputs"}</span></span>'
|
|
818
|
+
return f'<div class="pipeline-port-column {direction}">{body}</div>'
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def _render_port_dots(ports: Any, direction: str, left: int) -> str:
|
|
822
|
+
dot_left = left - 5
|
|
823
|
+
return "".join(
|
|
824
|
+
f'<span class="pipeline-port-dot {direction}" style="left:{dot_left}px;top:{PORT_Y_OFFSET + index * PORT_ROW_HEIGHT - 5}px"></span>'
|
|
825
|
+
for index, _ in enumerate(_visible_ports(ports))
|
|
826
|
+
)
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def _render_inspector(model: dict[str, Any], node_id: str) -> str:
|
|
830
|
+
node = next((item for item in model["nodes"] if item["id"] == node_id), None)
|
|
831
|
+
if node is None and model["nodes"]:
|
|
832
|
+
node = model["nodes"][0]
|
|
833
|
+
if node is None:
|
|
834
|
+
return '<div class="pipeline-empty-slot">No node selected</div>'
|
|
835
|
+
related = [
|
|
836
|
+
link
|
|
837
|
+
for link in model["links"]
|
|
838
|
+
if link["sourceNodeId"] == node["id"] or link["targetNodeId"] == node["id"]
|
|
839
|
+
]
|
|
840
|
+
return f"""
|
|
841
|
+
<div class="pipeline-sidebar-head">
|
|
842
|
+
<span class="panel-label">Inspector</span>
|
|
843
|
+
<strong>{_escape(node.get("label"))}</strong>
|
|
844
|
+
</div>
|
|
845
|
+
<div class="pipeline-inspector-body">
|
|
846
|
+
<div class="pipeline-inspector-meta">
|
|
847
|
+
<span>Node <strong>{_escape(node.get("subtitle") or node.get("rawId"))}</strong></span>
|
|
848
|
+
<span>Type <strong>{_escape(node.get("nodeKind") or node.get("kind"))}</strong></span>
|
|
849
|
+
{f'<span>Version <strong>{_escape(node.get("version"))}</strong></span>' if node.get("version") else ""}
|
|
850
|
+
</div>
|
|
851
|
+
<div class="pipeline-inspector-section">
|
|
852
|
+
<span class="panel-label">Inputs</span>
|
|
853
|
+
<div class="pipeline-chip-list">{"".join(_render_port_chip(port) for port in _visible_ports(node.get("inputs"))) or '<div class="pipeline-empty-slot">None</div>'}</div>
|
|
854
|
+
</div>
|
|
855
|
+
<div class="pipeline-inspector-section">
|
|
856
|
+
<span class="panel-label">Outputs</span>
|
|
857
|
+
<div class="pipeline-chip-list">{"".join(_render_port_chip(port) for port in _visible_ports(node.get("outputs"))) or '<div class="pipeline-empty-slot">None</div>'}</div>
|
|
858
|
+
</div>
|
|
859
|
+
<div class="pipeline-inspector-section">
|
|
860
|
+
<span class="panel-label">Links</span>
|
|
861
|
+
<div class="pipeline-link-mini-list">{"".join(_render_mini_link(link, node["id"]) for link in related) or '<div class="pipeline-empty-slot">No links</div>'}</div>
|
|
862
|
+
</div>
|
|
863
|
+
</div>
|
|
864
|
+
"""
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
def _render_port_chip(port: dict[str, Any]) -> str:
|
|
868
|
+
detail = f'<small>{_escape(port.get("detail"))}</small>' if port.get("detail") else ""
|
|
869
|
+
return f'<span class="pipeline-port-chip">{_escape(port.get("name"))}{detail}</span>'
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
def _render_mini_link(link: dict[str, Any], node_id: str) -> str:
|
|
873
|
+
outgoing = link["sourceNodeId"] == node_id
|
|
874
|
+
return f"""
|
|
875
|
+
<span class="pipeline-mini-link {'outgoing' if outgoing else 'incoming'}">
|
|
876
|
+
<small>{'out' if outgoing else 'in'}</small>
|
|
877
|
+
<strong>{_escape(link.get("sourceLabel"))} -> {_escape(link.get("targetLabel"))}</strong>
|
|
878
|
+
</span>
|
|
879
|
+
"""
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def _graph_tool(icon_name: str, label: str, control: str) -> str:
|
|
883
|
+
return f"""
|
|
884
|
+
<button class="pipeline-tool-button" type="button" data-pipeline-control="{_escape(control)}" title="{_escape(label)}" aria-label="{_escape(label)}">
|
|
885
|
+
{_icon(icon_name)}
|
|
886
|
+
</button>
|
|
887
|
+
"""
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
def _icon(name: str) -> str:
|
|
891
|
+
paths = {
|
|
892
|
+
"zoom-out": '<path d="M5 11a6 6 0 1 0 12 0 6 6 0 0 0-12 0Z"></path><path d="M8.5 11h5"></path><path d="m16 16 4 4"></path>',
|
|
893
|
+
"zoom-in": '<path d="M5 11a6 6 0 1 0 12 0 6 6 0 0 0-12 0Z"></path><path d="M8.5 11h5"></path><path d="M11 8.5v5"></path><path d="m16 16 4 4"></path>',
|
|
894
|
+
"target": '<circle cx="12" cy="12" r="7"></circle><circle cx="12" cy="12" r="2"></circle><path d="M12 2v3"></path><path d="M12 19v3"></path><path d="M2 12h3"></path><path d="M19 12h3"></path>',
|
|
895
|
+
"reset": '<path d="M3 12a9 9 0 1 0 3-6.7"></path><path d="M3 4v6h6"></path>',
|
|
896
|
+
"fullscreen": '<path d="M8 3H3v5"></path><path d="M16 3h5v5"></path><path d="M21 16v5h-5"></path><path d="M8 21H3v-5"></path>',
|
|
897
|
+
}
|
|
898
|
+
return f'<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{paths[name]}</svg>'
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def _widget_css(dom_id: str, height: int) -> str:
|
|
902
|
+
selector = f"#{dom_id}"
|
|
903
|
+
return f"""
|
|
904
|
+
{selector}.pipeline-graph-shell {{
|
|
905
|
+
--spl-bg: #09111d;
|
|
906
|
+
--spl-panel: #0f1b2b;
|
|
907
|
+
--spl-panel-2: #142337;
|
|
908
|
+
--spl-node: #16263a;
|
|
909
|
+
--spl-node-border: rgba(148, 163, 184, 0.3);
|
|
910
|
+
--spl-node-active: #38bdf8;
|
|
911
|
+
--spl-text: #e5edf7;
|
|
912
|
+
--spl-muted: #8ea1b8;
|
|
913
|
+
--spl-edge: #67e8f9;
|
|
914
|
+
--spl-input: #fbbf24;
|
|
915
|
+
--spl-output: #22c55e;
|
|
916
|
+
box-sizing: border-box;
|
|
917
|
+
display: flex;
|
|
918
|
+
flex-direction: column;
|
|
919
|
+
width: 100%;
|
|
920
|
+
min-height: {int(height)}px;
|
|
921
|
+
max-height: none;
|
|
922
|
+
overflow: hidden;
|
|
923
|
+
border: 1px solid rgba(148, 163, 184, 0.22);
|
|
924
|
+
border-radius: 8px;
|
|
925
|
+
background: var(--spl-bg);
|
|
926
|
+
color: var(--spl-text);
|
|
927
|
+
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
928
|
+
}}
|
|
929
|
+
{selector} *, {selector} *::before, {selector} *::after {{ box-sizing: border-box; }}
|
|
930
|
+
{selector} .panel-label {{
|
|
931
|
+
color: var(--spl-muted);
|
|
932
|
+
font-size: 11px;
|
|
933
|
+
font-weight: 700;
|
|
934
|
+
letter-spacing: 0;
|
|
935
|
+
text-transform: uppercase;
|
|
936
|
+
}}
|
|
937
|
+
{selector} .pipeline-graph-topbar {{
|
|
938
|
+
display: flex;
|
|
939
|
+
align-items: center;
|
|
940
|
+
justify-content: space-between;
|
|
941
|
+
gap: 16px;
|
|
942
|
+
padding: 14px 16px;
|
|
943
|
+
border-bottom: 1px solid rgba(148, 163, 184, 0.18);
|
|
944
|
+
background: rgba(15, 27, 43, 0.96);
|
|
945
|
+
}}
|
|
946
|
+
{selector} .pipeline-graph-title {{
|
|
947
|
+
display: grid;
|
|
948
|
+
gap: 3px;
|
|
949
|
+
min-width: 0;
|
|
950
|
+
}}
|
|
951
|
+
{selector} .pipeline-graph-title strong,
|
|
952
|
+
{selector} .pipeline-graph-title small {{
|
|
953
|
+
overflow: hidden;
|
|
954
|
+
text-overflow: ellipsis;
|
|
955
|
+
white-space: nowrap;
|
|
956
|
+
}}
|
|
957
|
+
{selector} .pipeline-graph-title strong {{ font-size: 15px; }}
|
|
958
|
+
{selector} .pipeline-graph-title small {{ color: var(--spl-muted); font-size: 12px; }}
|
|
959
|
+
{selector} .pipeline-graph-tools {{
|
|
960
|
+
display: flex;
|
|
961
|
+
align-items: center;
|
|
962
|
+
gap: 7px;
|
|
963
|
+
flex: 0 0 auto;
|
|
964
|
+
}}
|
|
965
|
+
{selector} .pipeline-tool-button {{
|
|
966
|
+
display: inline-grid;
|
|
967
|
+
place-items: center;
|
|
968
|
+
width: 34px;
|
|
969
|
+
height: 34px;
|
|
970
|
+
border: 1px solid rgba(148, 163, 184, 0.24);
|
|
971
|
+
border-radius: 7px;
|
|
972
|
+
color: var(--spl-text);
|
|
973
|
+
background: rgba(15, 23, 42, 0.76);
|
|
974
|
+
cursor: pointer;
|
|
975
|
+
}}
|
|
976
|
+
{selector} .pipeline-tool-button:hover,
|
|
977
|
+
{selector} .pipeline-tool-button:focus-visible,
|
|
978
|
+
{selector} .pipeline-tool-button.active {{
|
|
979
|
+
border-color: rgba(56, 189, 248, 0.78);
|
|
980
|
+
color: #e0f7ff;
|
|
981
|
+
outline: none;
|
|
982
|
+
}}
|
|
983
|
+
{selector} .pipeline-tool-button svg {{ width: 17px; height: 17px; }}
|
|
984
|
+
{selector} .pipeline-graph-workbench {{
|
|
985
|
+
display: grid;
|
|
986
|
+
grid-template-columns: minmax(150px, 210px) minmax(280px, 1fr) minmax(170px, 240px);
|
|
987
|
+
min-height: calc({int(height)}px - 64px);
|
|
988
|
+
}}
|
|
989
|
+
{selector}.pipeline-graph-is-fullscreen,
|
|
990
|
+
{selector}:fullscreen {{
|
|
991
|
+
position: fixed;
|
|
992
|
+
inset: 12px;
|
|
993
|
+
z-index: 999999;
|
|
994
|
+
width: auto;
|
|
995
|
+
height: auto;
|
|
996
|
+
min-height: 0;
|
|
997
|
+
}}
|
|
998
|
+
{selector}.pipeline-graph-is-fullscreen .pipeline-graph-workbench,
|
|
999
|
+
{selector}:fullscreen .pipeline-graph-workbench {{
|
|
1000
|
+
min-height: calc(100vh - 88px);
|
|
1001
|
+
}}
|
|
1002
|
+
{selector} .pipeline-graph-sidebar {{
|
|
1003
|
+
min-width: 0;
|
|
1004
|
+
border-right: 1px solid rgba(148, 163, 184, 0.16);
|
|
1005
|
+
background: rgba(15, 27, 43, 0.8);
|
|
1006
|
+
}}
|
|
1007
|
+
{selector} .pipeline-graph-inspector {{
|
|
1008
|
+
border-right: 0;
|
|
1009
|
+
border-left: 1px solid rgba(148, 163, 184, 0.16);
|
|
1010
|
+
}}
|
|
1011
|
+
{selector} .pipeline-sidebar-head {{
|
|
1012
|
+
display: grid;
|
|
1013
|
+
gap: 4px;
|
|
1014
|
+
padding: 14px 14px 10px;
|
|
1015
|
+
}}
|
|
1016
|
+
{selector} .pipeline-sidebar-head strong {{
|
|
1017
|
+
min-width: 0;
|
|
1018
|
+
overflow: hidden;
|
|
1019
|
+
text-overflow: ellipsis;
|
|
1020
|
+
white-space: nowrap;
|
|
1021
|
+
font-size: 13px;
|
|
1022
|
+
}}
|
|
1023
|
+
{selector} .pipeline-outliner-list {{
|
|
1024
|
+
display: grid;
|
|
1025
|
+
gap: 7px;
|
|
1026
|
+
max-height: calc({int(height)}px - 132px);
|
|
1027
|
+
overflow: auto;
|
|
1028
|
+
padding: 0 10px 14px;
|
|
1029
|
+
}}
|
|
1030
|
+
{selector} .pipeline-outliner-node {{
|
|
1031
|
+
display: grid;
|
|
1032
|
+
gap: 3px;
|
|
1033
|
+
width: 100%;
|
|
1034
|
+
padding: 9px 10px;
|
|
1035
|
+
border: 1px solid rgba(148, 163, 184, 0.18);
|
|
1036
|
+
border-radius: 7px;
|
|
1037
|
+
background: rgba(15, 23, 42, 0.82);
|
|
1038
|
+
color: var(--spl-text);
|
|
1039
|
+
text-align: left;
|
|
1040
|
+
cursor: pointer;
|
|
1041
|
+
}}
|
|
1042
|
+
{selector} .pipeline-outliner-node:hover,
|
|
1043
|
+
{selector} .pipeline-outliner-node:focus-visible,
|
|
1044
|
+
{selector} .pipeline-outliner-node.selected {{
|
|
1045
|
+
border-color: rgba(56, 189, 248, 0.76);
|
|
1046
|
+
outline: none;
|
|
1047
|
+
}}
|
|
1048
|
+
{selector} .pipeline-outliner-node span,
|
|
1049
|
+
{selector} .pipeline-outliner-node small {{
|
|
1050
|
+
min-width: 0;
|
|
1051
|
+
overflow: hidden;
|
|
1052
|
+
text-overflow: ellipsis;
|
|
1053
|
+
white-space: nowrap;
|
|
1054
|
+
}}
|
|
1055
|
+
{selector} .pipeline-outliner-node span {{ font-size: 12px; font-weight: 700; }}
|
|
1056
|
+
{selector} .pipeline-outliner-node small {{ color: var(--spl-muted); font-size: 11px; }}
|
|
1057
|
+
{selector} .pipeline-graph-viewport {{
|
|
1058
|
+
position: relative;
|
|
1059
|
+
min-height: calc({int(height)}px - 64px);
|
|
1060
|
+
overflow: hidden;
|
|
1061
|
+
background:
|
|
1062
|
+
linear-gradient(rgba(148, 163, 184, 0.05) 1px, transparent 1px),
|
|
1063
|
+
linear-gradient(90deg, rgba(148, 163, 184, 0.05) 1px, transparent 1px),
|
|
1064
|
+
#0a1321;
|
|
1065
|
+
background-size: 28px 28px;
|
|
1066
|
+
cursor: grab;
|
|
1067
|
+
}}
|
|
1068
|
+
{selector} .pipeline-graph-viewport.panning {{ cursor: grabbing; }}
|
|
1069
|
+
{selector} .pipeline-graph-stage {{
|
|
1070
|
+
position: absolute;
|
|
1071
|
+
left: 0;
|
|
1072
|
+
top: 0;
|
|
1073
|
+
transform-origin: 0 0;
|
|
1074
|
+
}}
|
|
1075
|
+
{selector} .pipeline-graph-edges {{
|
|
1076
|
+
position: absolute;
|
|
1077
|
+
inset: 0;
|
|
1078
|
+
overflow: visible;
|
|
1079
|
+
pointer-events: none;
|
|
1080
|
+
}}
|
|
1081
|
+
{selector} .pipeline-graph-edge path {{
|
|
1082
|
+
fill: none;
|
|
1083
|
+
stroke: var(--spl-edge);
|
|
1084
|
+
stroke-width: 2.2;
|
|
1085
|
+
stroke-linecap: round;
|
|
1086
|
+
stroke-linejoin: round;
|
|
1087
|
+
opacity: 0.78;
|
|
1088
|
+
}}
|
|
1089
|
+
{selector} .pipeline-graph-edge text {{
|
|
1090
|
+
fill: #bfefff;
|
|
1091
|
+
font-size: 11px;
|
|
1092
|
+
paint-order: stroke;
|
|
1093
|
+
stroke: #07111f;
|
|
1094
|
+
stroke-width: 4px;
|
|
1095
|
+
text-anchor: middle;
|
|
1096
|
+
}}
|
|
1097
|
+
{selector} marker path {{ fill: var(--spl-edge); }}
|
|
1098
|
+
{selector} .pipeline-graph-node {{
|
|
1099
|
+
position: absolute;
|
|
1100
|
+
display: grid;
|
|
1101
|
+
grid-template-rows: auto 1fr;
|
|
1102
|
+
border: 1px solid var(--spl-node-border);
|
|
1103
|
+
border-radius: 8px;
|
|
1104
|
+
background: linear-gradient(180deg, rgba(30, 49, 73, 0.96), var(--spl-node));
|
|
1105
|
+
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.26);
|
|
1106
|
+
overflow: visible;
|
|
1107
|
+
}}
|
|
1108
|
+
{selector} .pipeline-graph-node:hover,
|
|
1109
|
+
{selector} .pipeline-graph-node:focus-visible,
|
|
1110
|
+
{selector} .pipeline-graph-node.selected {{
|
|
1111
|
+
border-color: rgba(56, 189, 248, 0.9);
|
|
1112
|
+
outline: none;
|
|
1113
|
+
}}
|
|
1114
|
+
{selector} .pipeline-graph-node.external {{
|
|
1115
|
+
background: linear-gradient(180deg, rgba(55, 43, 20, 0.96), rgba(32, 27, 17, 0.98));
|
|
1116
|
+
}}
|
|
1117
|
+
{selector} .pipeline-graph-node-head {{
|
|
1118
|
+
display: grid;
|
|
1119
|
+
gap: 3px;
|
|
1120
|
+
padding: 12px 14px 10px;
|
|
1121
|
+
border-bottom: 1px solid rgba(148, 163, 184, 0.16);
|
|
1122
|
+
}}
|
|
1123
|
+
{selector} .pipeline-graph-node-head span,
|
|
1124
|
+
{selector} .pipeline-graph-node-head strong {{
|
|
1125
|
+
min-width: 0;
|
|
1126
|
+
overflow: hidden;
|
|
1127
|
+
text-overflow: ellipsis;
|
|
1128
|
+
white-space: nowrap;
|
|
1129
|
+
}}
|
|
1130
|
+
{selector} .pipeline-graph-node-head span {{
|
|
1131
|
+
color: var(--spl-muted);
|
|
1132
|
+
font-size: 10px;
|
|
1133
|
+
}}
|
|
1134
|
+
{selector} .pipeline-graph-node-head strong {{ font-size: 13px; }}
|
|
1135
|
+
{selector} .pipeline-graph-node-ports {{
|
|
1136
|
+
display: grid;
|
|
1137
|
+
grid-template-columns: 1fr 1fr;
|
|
1138
|
+
gap: 16px;
|
|
1139
|
+
padding: 12px 14px 14px;
|
|
1140
|
+
}}
|
|
1141
|
+
{selector} .pipeline-port-column {{
|
|
1142
|
+
display: grid;
|
|
1143
|
+
align-content: start;
|
|
1144
|
+
gap: 6px;
|
|
1145
|
+
min-width: 0;
|
|
1146
|
+
}}
|
|
1147
|
+
{selector} .pipeline-port-column.output {{ text-align: right; }}
|
|
1148
|
+
{selector} .pipeline-port-row {{
|
|
1149
|
+
display: grid;
|
|
1150
|
+
gap: 2px;
|
|
1151
|
+
min-height: 22px;
|
|
1152
|
+
}}
|
|
1153
|
+
{selector} .pipeline-port-row span,
|
|
1154
|
+
{selector} .pipeline-port-row small {{
|
|
1155
|
+
min-width: 0;
|
|
1156
|
+
overflow: hidden;
|
|
1157
|
+
text-overflow: ellipsis;
|
|
1158
|
+
white-space: nowrap;
|
|
1159
|
+
}}
|
|
1160
|
+
{selector} .pipeline-port-row span {{ font-size: 11px; font-weight: 700; }}
|
|
1161
|
+
{selector} .pipeline-port-row small {{ color: var(--spl-muted); font-size: 10px; }}
|
|
1162
|
+
{selector} .pipeline-port-row.muted span {{ color: var(--spl-muted); font-weight: 600; }}
|
|
1163
|
+
{selector} .pipeline-port-dot {{
|
|
1164
|
+
position: absolute;
|
|
1165
|
+
width: 10px;
|
|
1166
|
+
height: 10px;
|
|
1167
|
+
border: 2px solid #07111f;
|
|
1168
|
+
border-radius: 999px;
|
|
1169
|
+
background: var(--spl-output);
|
|
1170
|
+
}}
|
|
1171
|
+
{selector} .pipeline-port-dot.input {{ background: var(--spl-input); }}
|
|
1172
|
+
{selector} .pipeline-graph-node.external .pipeline-port-dot {{ background: #fbbf24; }}
|
|
1173
|
+
{selector} .pipeline-inspector-body,
|
|
1174
|
+
{selector} .pipeline-inspector-section {{
|
|
1175
|
+
display: grid;
|
|
1176
|
+
gap: 10px;
|
|
1177
|
+
}}
|
|
1178
|
+
{selector} .pipeline-inspector-body {{ padding: 0 14px 14px; }}
|
|
1179
|
+
{selector} .pipeline-inspector-meta {{
|
|
1180
|
+
display: grid;
|
|
1181
|
+
gap: 7px;
|
|
1182
|
+
color: var(--spl-muted);
|
|
1183
|
+
font-size: 11px;
|
|
1184
|
+
}}
|
|
1185
|
+
{selector} .pipeline-inspector-meta span {{
|
|
1186
|
+
display: flex;
|
|
1187
|
+
justify-content: space-between;
|
|
1188
|
+
gap: 8px;
|
|
1189
|
+
min-width: 0;
|
|
1190
|
+
}}
|
|
1191
|
+
{selector} .pipeline-inspector-meta strong {{
|
|
1192
|
+
color: var(--spl-text);
|
|
1193
|
+
overflow: hidden;
|
|
1194
|
+
text-overflow: ellipsis;
|
|
1195
|
+
white-space: nowrap;
|
|
1196
|
+
}}
|
|
1197
|
+
{selector} .pipeline-chip-list,
|
|
1198
|
+
{selector} .pipeline-link-mini-list {{
|
|
1199
|
+
display: flex;
|
|
1200
|
+
flex-wrap: wrap;
|
|
1201
|
+
gap: 7px;
|
|
1202
|
+
}}
|
|
1203
|
+
{selector} .pipeline-port-chip,
|
|
1204
|
+
{selector} .pipeline-mini-link,
|
|
1205
|
+
{selector} .pipeline-empty-slot {{
|
|
1206
|
+
border: 1px solid rgba(148, 163, 184, 0.18);
|
|
1207
|
+
border-radius: 7px;
|
|
1208
|
+
background: rgba(15, 23, 42, 0.72);
|
|
1209
|
+
color: var(--spl-text);
|
|
1210
|
+
font-size: 11px;
|
|
1211
|
+
}}
|
|
1212
|
+
{selector} .pipeline-port-chip {{
|
|
1213
|
+
display: inline-grid;
|
|
1214
|
+
gap: 2px;
|
|
1215
|
+
padding: 6px 8px;
|
|
1216
|
+
}}
|
|
1217
|
+
{selector} .pipeline-port-chip small {{
|
|
1218
|
+
color: var(--spl-muted);
|
|
1219
|
+
font-size: 10px;
|
|
1220
|
+
}}
|
|
1221
|
+
{selector} .pipeline-mini-link {{
|
|
1222
|
+
display: grid;
|
|
1223
|
+
gap: 3px;
|
|
1224
|
+
max-width: 100%;
|
|
1225
|
+
padding: 7px 8px;
|
|
1226
|
+
}}
|
|
1227
|
+
{selector} .pipeline-mini-link small {{ color: var(--spl-muted); }}
|
|
1228
|
+
{selector} .pipeline-mini-link strong {{
|
|
1229
|
+
min-width: 0;
|
|
1230
|
+
overflow: hidden;
|
|
1231
|
+
text-overflow: ellipsis;
|
|
1232
|
+
white-space: nowrap;
|
|
1233
|
+
}}
|
|
1234
|
+
{selector} .pipeline-empty-slot {{
|
|
1235
|
+
padding: 9px 10px;
|
|
1236
|
+
color: var(--spl-muted);
|
|
1237
|
+
}}
|
|
1238
|
+
@media (max-width: 860px) {{
|
|
1239
|
+
{selector} .pipeline-graph-workbench {{
|
|
1240
|
+
grid-template-columns: 1fr;
|
|
1241
|
+
}}
|
|
1242
|
+
{selector} .pipeline-graph-sidebar {{
|
|
1243
|
+
display: none;
|
|
1244
|
+
}}
|
|
1245
|
+
{selector} .pipeline-graph-viewport {{
|
|
1246
|
+
min-height: {max(360, int(height) - 68)}px;
|
|
1247
|
+
}}
|
|
1248
|
+
}}
|
|
1249
|
+
"""
|
|
1250
|
+
|
|
1251
|
+
|
|
1252
|
+
def _widget_js(dom_id: str) -> str:
|
|
1253
|
+
safe_dom_id = json.dumps(dom_id)
|
|
1254
|
+
return f"""
|
|
1255
|
+
(function () {{
|
|
1256
|
+
const root = document.getElementById({safe_dom_id});
|
|
1257
|
+
if (!root || root.dataset.splPipelineReady === "true") return;
|
|
1258
|
+
root.dataset.splPipelineReady = "true";
|
|
1259
|
+
const modelScript = root.querySelector("[data-spl-pipeline-model]");
|
|
1260
|
+
const model = JSON.parse(modelScript.textContent || "{{}}");
|
|
1261
|
+
const viewport = root.querySelector("[data-pipeline-viewport]");
|
|
1262
|
+
const stage = root.querySelector("[data-pipeline-stage]");
|
|
1263
|
+
const inspector = root.querySelector("[data-pipeline-inspector]");
|
|
1264
|
+
const state = {{
|
|
1265
|
+
scale: 1,
|
|
1266
|
+
panX: 28,
|
|
1267
|
+
panY: 28,
|
|
1268
|
+
dragging: false,
|
|
1269
|
+
dragStart: null,
|
|
1270
|
+
selectedNodeId: model.selectedNodeId || ""
|
|
1271
|
+
}};
|
|
1272
|
+
|
|
1273
|
+
function escapeHtml(value) {{
|
|
1274
|
+
return String(value == null ? "" : value).replace(/[&<>"']/g, function (char) {{
|
|
1275
|
+
return ({{ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }})[char];
|
|
1276
|
+
}});
|
|
1277
|
+
}}
|
|
1278
|
+
|
|
1279
|
+
function clamp(value, min, max) {{
|
|
1280
|
+
return Math.max(min, Math.min(max, value));
|
|
1281
|
+
}}
|
|
1282
|
+
|
|
1283
|
+
function visiblePorts(ports) {{
|
|
1284
|
+
return Array.isArray(ports) ? ports.filter(function (port) {{ return !port.hidden; }}) : [];
|
|
1285
|
+
}}
|
|
1286
|
+
|
|
1287
|
+
function applyTransform() {{
|
|
1288
|
+
if (!stage) return;
|
|
1289
|
+
stage.style.transform = "translate(" + Math.round(state.panX) + "px, " + Math.round(state.panY) + "px) scale(" + state.scale.toFixed(3) + ")";
|
|
1290
|
+
}}
|
|
1291
|
+
|
|
1292
|
+
function fitGraph() {{
|
|
1293
|
+
if (!viewport || !stage) return;
|
|
1294
|
+
const width = viewport.clientWidth || 720;
|
|
1295
|
+
const height = viewport.clientHeight || 420;
|
|
1296
|
+
const stageWidth = model.stageWidth || 640;
|
|
1297
|
+
const stageHeight = model.stageHeight || 360;
|
|
1298
|
+
const minReadableScale = width < 640 ? {MIN_SCALE} : 0.72;
|
|
1299
|
+
state.scale = clamp(Math.min(width / stageWidth, height / stageHeight) * 0.92, minReadableScale, 1);
|
|
1300
|
+
state.panX = Math.round((width - stageWidth * state.scale) / 2);
|
|
1301
|
+
state.panY = Math.round((height - stageHeight * state.scale) / 2);
|
|
1302
|
+
applyTransform();
|
|
1303
|
+
}}
|
|
1304
|
+
|
|
1305
|
+
function zoomGraph(nextScale, originX, originY) {{
|
|
1306
|
+
const previous = state.scale;
|
|
1307
|
+
const scale = clamp(nextScale, {MIN_SCALE}, {MAX_SCALE});
|
|
1308
|
+
if (scale === previous) return;
|
|
1309
|
+
if (originX != null && originY != null) {{
|
|
1310
|
+
const worldX = (originX - state.panX) / previous;
|
|
1311
|
+
const worldY = (originY - state.panY) / previous;
|
|
1312
|
+
state.panX = originX - worldX * scale;
|
|
1313
|
+
state.panY = originY - worldY * scale;
|
|
1314
|
+
}}
|
|
1315
|
+
state.scale = scale;
|
|
1316
|
+
applyTransform();
|
|
1317
|
+
}}
|
|
1318
|
+
|
|
1319
|
+
function renderPortChip(port) {{
|
|
1320
|
+
return '<span class="pipeline-port-chip">' + escapeHtml(port.name) +
|
|
1321
|
+
(port.detail ? '<small>' + escapeHtml(port.detail) + '</small>' : '') +
|
|
1322
|
+
'</span>';
|
|
1323
|
+
}}
|
|
1324
|
+
|
|
1325
|
+
function renderMiniLink(link, nodeId) {{
|
|
1326
|
+
const outgoing = link.sourceNodeId === nodeId;
|
|
1327
|
+
return '<span class="pipeline-mini-link ' + (outgoing ? "outgoing" : "incoming") + '">' +
|
|
1328
|
+
'<small>' + (outgoing ? "out" : "in") + '</small>' +
|
|
1329
|
+
'<strong>' + escapeHtml(link.sourceLabel) + ' -> ' + escapeHtml(link.targetLabel) + '</strong>' +
|
|
1330
|
+
'</span>';
|
|
1331
|
+
}}
|
|
1332
|
+
|
|
1333
|
+
function renderInspector(nodeId) {{
|
|
1334
|
+
if (!inspector) return;
|
|
1335
|
+
const node = model.nodes.find(function (item) {{ return item.id === nodeId; }}) || model.nodes[0];
|
|
1336
|
+
if (!node) {{
|
|
1337
|
+
inspector.innerHTML = '<div class="pipeline-empty-slot">No node selected</div>';
|
|
1338
|
+
return;
|
|
1339
|
+
}}
|
|
1340
|
+
const links = model.links.filter(function (link) {{
|
|
1341
|
+
return link.sourceNodeId === node.id || link.targetNodeId === node.id;
|
|
1342
|
+
}});
|
|
1343
|
+
inspector.innerHTML =
|
|
1344
|
+
'<div class="pipeline-sidebar-head">' +
|
|
1345
|
+
'<span class="panel-label">Inspector</span>' +
|
|
1346
|
+
'<strong>' + escapeHtml(node.label) + '</strong>' +
|
|
1347
|
+
'</div>' +
|
|
1348
|
+
'<div class="pipeline-inspector-body">' +
|
|
1349
|
+
'<div class="pipeline-inspector-meta">' +
|
|
1350
|
+
'<span>Node <strong>' + escapeHtml(node.subtitle || node.rawId) + '</strong></span>' +
|
|
1351
|
+
'<span>Type <strong>' + escapeHtml(node.nodeKind || node.kind) + '</strong></span>' +
|
|
1352
|
+
(node.version ? '<span>Version <strong>' + escapeHtml(node.version) + '</strong></span>' : '') +
|
|
1353
|
+
'</div>' +
|
|
1354
|
+
'<div class="pipeline-inspector-section"><span class="panel-label">Inputs</span>' +
|
|
1355
|
+
'<div class="pipeline-chip-list">' + (visiblePorts(node.inputs).map(renderPortChip).join("") || '<div class="pipeline-empty-slot">None</div>') + '</div>' +
|
|
1356
|
+
'</div>' +
|
|
1357
|
+
'<div class="pipeline-inspector-section"><span class="panel-label">Outputs</span>' +
|
|
1358
|
+
'<div class="pipeline-chip-list">' + (visiblePorts(node.outputs).map(renderPortChip).join("") || '<div class="pipeline-empty-slot">None</div>') + '</div>' +
|
|
1359
|
+
'</div>' +
|
|
1360
|
+
'<div class="pipeline-inspector-section"><span class="panel-label">Links</span>' +
|
|
1361
|
+
'<div class="pipeline-link-mini-list">' + (links.map(function (link) {{ return renderMiniLink(link, node.id); }}).join("") || '<div class="pipeline-empty-slot">No links</div>') + '</div>' +
|
|
1362
|
+
'</div>' +
|
|
1363
|
+
'</div>';
|
|
1364
|
+
}}
|
|
1365
|
+
|
|
1366
|
+
function selectNode(nodeId) {{
|
|
1367
|
+
if (!nodeId || !model.nodes.some(function (node) {{ return node.id === nodeId; }})) return;
|
|
1368
|
+
state.selectedNodeId = nodeId;
|
|
1369
|
+
root.querySelectorAll("[data-pipeline-node-id]").forEach(function (node) {{
|
|
1370
|
+
node.classList.toggle("selected", node.dataset.pipelineNodeId === nodeId);
|
|
1371
|
+
}});
|
|
1372
|
+
root.querySelectorAll("[data-pipeline-select-node]").forEach(function (node) {{
|
|
1373
|
+
node.classList.toggle("selected", node.dataset.pipelineSelectNode === nodeId);
|
|
1374
|
+
}});
|
|
1375
|
+
renderInspector(nodeId);
|
|
1376
|
+
}}
|
|
1377
|
+
|
|
1378
|
+
root.addEventListener("click", function (event) {{
|
|
1379
|
+
const target = event.target;
|
|
1380
|
+
if (!(target instanceof Element)) return;
|
|
1381
|
+
const control = target.closest("[data-pipeline-control]");
|
|
1382
|
+
if (control) {{
|
|
1383
|
+
const action = control.dataset.pipelineControl;
|
|
1384
|
+
if (action === "fit") fitGraph();
|
|
1385
|
+
if (action === "reset") {{ state.scale = 1; state.panX = 28; state.panY = 28; applyTransform(); }}
|
|
1386
|
+
if (action === "zoomIn") zoomGraph(state.scale + 0.14);
|
|
1387
|
+
if (action === "zoomOut") zoomGraph(state.scale - 0.14);
|
|
1388
|
+
if (action === "fullscreen") {{
|
|
1389
|
+
root.classList.toggle("pipeline-graph-is-fullscreen");
|
|
1390
|
+
setTimeout(fitGraph, 40);
|
|
1391
|
+
}}
|
|
1392
|
+
return;
|
|
1393
|
+
}}
|
|
1394
|
+
const outlinerNode = target.closest("[data-pipeline-select-node]");
|
|
1395
|
+
if (outlinerNode) {{
|
|
1396
|
+
selectNode(outlinerNode.dataset.pipelineSelectNode);
|
|
1397
|
+
return;
|
|
1398
|
+
}}
|
|
1399
|
+
const graphNode = target.closest("[data-pipeline-node-id]");
|
|
1400
|
+
if (graphNode) selectNode(graphNode.dataset.pipelineNodeId);
|
|
1401
|
+
}});
|
|
1402
|
+
|
|
1403
|
+
root.addEventListener("keydown", function (event) {{
|
|
1404
|
+
const target = event.target;
|
|
1405
|
+
if (!(target instanceof Element)) return;
|
|
1406
|
+
const graphNode = target.closest("[data-pipeline-node-id]");
|
|
1407
|
+
if (graphNode && (event.key === "Enter" || event.key === " ")) {{
|
|
1408
|
+
event.preventDefault();
|
|
1409
|
+
selectNode(graphNode.dataset.pipelineNodeId);
|
|
1410
|
+
}}
|
|
1411
|
+
if (event.key === "Escape" && root.classList.contains("pipeline-graph-is-fullscreen")) {{
|
|
1412
|
+
root.classList.remove("pipeline-graph-is-fullscreen");
|
|
1413
|
+
setTimeout(fitGraph, 40);
|
|
1414
|
+
}}
|
|
1415
|
+
}});
|
|
1416
|
+
|
|
1417
|
+
if (viewport) {{
|
|
1418
|
+
viewport.addEventListener("wheel", function (event) {{
|
|
1419
|
+
event.preventDefault();
|
|
1420
|
+
const delta = event.deltaY > 0 ? -0.08 : 0.08;
|
|
1421
|
+
const rect = viewport.getBoundingClientRect();
|
|
1422
|
+
zoomGraph(state.scale + delta, event.clientX - rect.left, event.clientY - rect.top);
|
|
1423
|
+
}}, {{ passive: false }});
|
|
1424
|
+
|
|
1425
|
+
viewport.addEventListener("pointerdown", function (event) {{
|
|
1426
|
+
if (event.button !== 0 || event.target.closest && event.target.closest("[data-pipeline-node-id],button,a")) return;
|
|
1427
|
+
viewport.setPointerCapture && viewport.setPointerCapture(event.pointerId);
|
|
1428
|
+
state.dragging = true;
|
|
1429
|
+
state.dragStart = {{ x: event.clientX, y: event.clientY, panX: state.panX, panY: state.panY }};
|
|
1430
|
+
viewport.classList.add("panning");
|
|
1431
|
+
}});
|
|
1432
|
+
viewport.addEventListener("pointermove", function (event) {{
|
|
1433
|
+
if (!state.dragging) return;
|
|
1434
|
+
state.panX = state.dragStart.panX + event.clientX - state.dragStart.x;
|
|
1435
|
+
state.panY = state.dragStart.panY + event.clientY - state.dragStart.y;
|
|
1436
|
+
applyTransform();
|
|
1437
|
+
}});
|
|
1438
|
+
viewport.addEventListener("pointerup", function (event) {{
|
|
1439
|
+
if (!state.dragging) return;
|
|
1440
|
+
viewport.releasePointerCapture && viewport.releasePointerCapture(event.pointerId);
|
|
1441
|
+
state.dragging = false;
|
|
1442
|
+
viewport.classList.remove("panning");
|
|
1443
|
+
}});
|
|
1444
|
+
}}
|
|
1445
|
+
|
|
1446
|
+
applyTransform();
|
|
1447
|
+
setTimeout(fitGraph, 30);
|
|
1448
|
+
if (typeof ResizeObserver !== "undefined" && viewport) {{
|
|
1449
|
+
new ResizeObserver(function () {{ fitGraph(); }}).observe(viewport);
|
|
1450
|
+
}}
|
|
1451
|
+
}})();
|
|
1452
|
+
"""
|