panel-reactflow 0.0.1a1__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.
- panel_reactflow/__init__.py +12 -0
- panel_reactflow/__version.py +44 -0
- panel_reactflow/base.py +706 -0
- panel_reactflow/models/reactflow.jsx +561 -0
- panel_reactflow/py.typed +0 -0
- panel_reactflow-0.0.1a1.dist-info/METADATA +158 -0
- panel_reactflow-0.0.1a1.dist-info/RECORD +9 -0
- panel_reactflow-0.0.1a1.dist-info/WHEEL +4 -0
- panel_reactflow-0.0.1a1.dist-info/licenses/LICENSE.txt +30 -0
panel_reactflow/base.py
ADDED
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
"""Core React Flow component and helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from dataclasses import asdict, dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
12
|
+
from uuid import uuid4
|
|
13
|
+
|
|
14
|
+
import panel as pn
|
|
15
|
+
import param
|
|
16
|
+
from bokeh.embed.bundle import extension_dirs
|
|
17
|
+
from panel.config import config
|
|
18
|
+
from panel.custom import Children, ReactComponent
|
|
19
|
+
from panel.io.resources import EXTENSION_CDN
|
|
20
|
+
from panel.io.state import state
|
|
21
|
+
from panel.util import base_version, classproperty
|
|
22
|
+
from panel.viewable import Viewer
|
|
23
|
+
from panel.widgets import JSONEditor
|
|
24
|
+
from panel_material_ui import Paper
|
|
25
|
+
|
|
26
|
+
from .__version import __version__ # noqa
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from bokeh.models import UIElement
|
|
30
|
+
|
|
31
|
+
IS_RELEASE = __version__ == base_version(__version__)
|
|
32
|
+
BASE_PATH = Path(__file__).parent
|
|
33
|
+
DIST_PATH = BASE_PATH / "dist"
|
|
34
|
+
CDN_BASE = f"https://cdn.holoviz.org/panel-reactflow/v{base_version(__version__)}"
|
|
35
|
+
CDN_DIST = f"{CDN_BASE}/panel-reactflow.bundle.js"
|
|
36
|
+
|
|
37
|
+
extension_dirs["panel-reactflow"] = DIST_PATH
|
|
38
|
+
EXTENSION_CDN[DIST_PATH] = CDN_BASE
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _ensure_jsonable(value: Any, path: str) -> None:
|
|
42
|
+
"""Ensure value can be JSON-serialized for syncing to the frontend."""
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
json.dumps(value)
|
|
46
|
+
except Exception as exc:
|
|
47
|
+
raise ValueError(f"Value at {path} is not JSON-serializable.") from exc
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _coerce_spec_map(specs: dict[str, Any] | None) -> dict[str, dict[str, Any]]:
|
|
51
|
+
if not specs:
|
|
52
|
+
return {}
|
|
53
|
+
normalized: dict[str, dict[str, Any]] = {}
|
|
54
|
+
for key, value in specs.items():
|
|
55
|
+
if hasattr(value, "to_dict"):
|
|
56
|
+
normalized[key] = value.to_dict()
|
|
57
|
+
elif isinstance(value, dict):
|
|
58
|
+
normalized[key] = value
|
|
59
|
+
else:
|
|
60
|
+
raise ValueError(f"Unsupported spec type for '{key}'.")
|
|
61
|
+
return normalized
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class PropertySpec:
|
|
66
|
+
"""Schema definition for a node or edge property."""
|
|
67
|
+
|
|
68
|
+
name: str
|
|
69
|
+
type: str = "str"
|
|
70
|
+
default: Any = None
|
|
71
|
+
label: str | None = None
|
|
72
|
+
help: str | None = None
|
|
73
|
+
choices: list[Any] | None = None
|
|
74
|
+
format: str | None = None
|
|
75
|
+
visible_in_node: bool = False
|
|
76
|
+
editable: bool = True
|
|
77
|
+
|
|
78
|
+
def to_dict(self) -> dict[str, Any]:
|
|
79
|
+
return asdict(self)
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def from_dict(cls, payload: dict[str, Any]) -> "PropertySpec":
|
|
83
|
+
return cls(**payload)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class NodeTypeSpec:
|
|
88
|
+
"""Schema definition for a node type."""
|
|
89
|
+
|
|
90
|
+
type: str
|
|
91
|
+
label: str | None = None
|
|
92
|
+
properties: list[PropertySpec] | None = None
|
|
93
|
+
inputs: list[str] | None = None
|
|
94
|
+
outputs: list[str] | None = None
|
|
95
|
+
pane_policy: str = "single"
|
|
96
|
+
|
|
97
|
+
def to_dict(self) -> dict[str, Any]:
|
|
98
|
+
payload = asdict(self)
|
|
99
|
+
payload["properties"] = [p.to_dict() for p in self.properties or []]
|
|
100
|
+
return payload
|
|
101
|
+
|
|
102
|
+
@classmethod
|
|
103
|
+
def from_dict(cls, payload: dict[str, Any]) -> "NodeTypeSpec":
|
|
104
|
+
props = [PropertySpec.from_dict(p) for p in payload.get("properties", [])]
|
|
105
|
+
return cls(**{**payload, "properties": props})
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass
|
|
109
|
+
class EdgeTypeSpec:
|
|
110
|
+
"""Schema definition for an edge type."""
|
|
111
|
+
|
|
112
|
+
type: str
|
|
113
|
+
properties: list[PropertySpec] | None = None
|
|
114
|
+
|
|
115
|
+
def to_dict(self) -> dict[str, Any]:
|
|
116
|
+
payload = asdict(self)
|
|
117
|
+
payload["properties"] = [p.to_dict() for p in self.properties or []]
|
|
118
|
+
return payload
|
|
119
|
+
|
|
120
|
+
@classmethod
|
|
121
|
+
def from_dict(cls, payload: dict[str, Any]) -> "EdgeTypeSpec":
|
|
122
|
+
props = [PropertySpec.from_dict(p) for p in payload.get("properties", [])]
|
|
123
|
+
return cls(**{**payload, "properties": props})
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass
|
|
127
|
+
class NodeSpec:
|
|
128
|
+
"""Helper for constructing node dictionaries."""
|
|
129
|
+
|
|
130
|
+
id: str
|
|
131
|
+
position: dict[str, float] | dict[str, Any] = None
|
|
132
|
+
type: str = "panel"
|
|
133
|
+
data: dict[str, Any] | None = None
|
|
134
|
+
selected: bool = False
|
|
135
|
+
draggable: bool = True
|
|
136
|
+
connectable: bool = True
|
|
137
|
+
deletable: bool = True
|
|
138
|
+
style: dict[str, Any] | None = None
|
|
139
|
+
className: str | None = None
|
|
140
|
+
|
|
141
|
+
def __post_init__(self) -> None:
|
|
142
|
+
if self.position is None:
|
|
143
|
+
self.position = {"x": 0.0, "y": 0.0}
|
|
144
|
+
if self.data is None:
|
|
145
|
+
self.data = {}
|
|
146
|
+
|
|
147
|
+
def to_dict(self) -> dict[str, Any]:
|
|
148
|
+
payload = {
|
|
149
|
+
"id": self.id,
|
|
150
|
+
"position": self.position,
|
|
151
|
+
"type": self.type,
|
|
152
|
+
"data": self.data,
|
|
153
|
+
"selected": self.selected,
|
|
154
|
+
"draggable": self.draggable,
|
|
155
|
+
"connectable": self.connectable,
|
|
156
|
+
"deletable": self.deletable,
|
|
157
|
+
}
|
|
158
|
+
if self.style is not None:
|
|
159
|
+
payload["style"] = self.style
|
|
160
|
+
if self.className is not None:
|
|
161
|
+
payload["className"] = self.className
|
|
162
|
+
return payload
|
|
163
|
+
|
|
164
|
+
@classmethod
|
|
165
|
+
def from_dict(cls, payload: dict[str, Any]) -> "NodeSpec":
|
|
166
|
+
return cls(**payload)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@dataclass
|
|
170
|
+
class EdgeSpec:
|
|
171
|
+
"""Helper for constructing edge dictionaries."""
|
|
172
|
+
|
|
173
|
+
id: str
|
|
174
|
+
source: str
|
|
175
|
+
target: str
|
|
176
|
+
label: str | None = None
|
|
177
|
+
type: str | None = None
|
|
178
|
+
selected: bool = False
|
|
179
|
+
data: dict[str, Any] | None = None
|
|
180
|
+
style: dict[str, Any] | None = None
|
|
181
|
+
markerEnd: dict[str, Any] | None = None
|
|
182
|
+
|
|
183
|
+
def __post_init__(self) -> None:
|
|
184
|
+
if self.data is None:
|
|
185
|
+
self.data = {}
|
|
186
|
+
|
|
187
|
+
def to_dict(self) -> dict[str, Any]:
|
|
188
|
+
payload = {
|
|
189
|
+
"id": self.id,
|
|
190
|
+
"source": self.source,
|
|
191
|
+
"target": self.target,
|
|
192
|
+
"label": self.label,
|
|
193
|
+
"type": self.type,
|
|
194
|
+
"selected": self.selected,
|
|
195
|
+
"data": self.data,
|
|
196
|
+
}
|
|
197
|
+
if self.style is not None:
|
|
198
|
+
payload["style"] = self.style
|
|
199
|
+
if self.markerEnd is not None:
|
|
200
|
+
payload["markerEnd"] = self.markerEnd
|
|
201
|
+
return payload
|
|
202
|
+
|
|
203
|
+
@classmethod
|
|
204
|
+
def from_dict(cls, payload: dict[str, Any]) -> "EdgeSpec":
|
|
205
|
+
return cls(**payload)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class NodeEditor(Viewer):
|
|
209
|
+
_id = param.String(default="", doc="ID of the node.", constant=True)
|
|
210
|
+
_data = param.Dict(default={}, doc="Data for the node.")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class JsonNodeEditor(NodeEditor):
|
|
214
|
+
def __panel__(self):
|
|
215
|
+
return JSONEditor.from_param(self.param._data)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class ParamNodeEditor(NodeEditor):
|
|
219
|
+
def __init__(self, **params):
|
|
220
|
+
params.update({p: v for p, v in params.get("_data", {}).items() if p in type(self).param})
|
|
221
|
+
super().__init__(**params)
|
|
222
|
+
edit_params = [p for p in self.param if p not in NodeEditor.param]
|
|
223
|
+
self.param.watch(self._update_data, edit_params)
|
|
224
|
+
self._panel = pn.Param(self, parameters=edit_params, show_name=False, margin=0, default_layout=Paper)
|
|
225
|
+
|
|
226
|
+
def _update_data(self, *events: tuple[param.parameterized.Event]) -> None:
|
|
227
|
+
self._data = dict(self._data, **{event.name: event.new for event in events})
|
|
228
|
+
|
|
229
|
+
def __panel__(self):
|
|
230
|
+
return self._panel
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class ReactFlow(ReactComponent):
|
|
234
|
+
"""React Flow component wrapper."""
|
|
235
|
+
|
|
236
|
+
nodes = param.List(default=[], doc="Canonical list of node dictionaries.")
|
|
237
|
+
edges = param.List(default=[], doc="Canonical list of edge dictionaries.")
|
|
238
|
+
node_types = param.Dict(default={}, doc="Node type schema definitions keyed by type name.", precedence=-1)
|
|
239
|
+
edge_types = param.Dict(default={}, doc="Edge type schema definitions keyed by type name.", precedence=-1)
|
|
240
|
+
|
|
241
|
+
debounce_ms = param.Integer(default=150, bounds=(0, None), doc="Debounce delay in milliseconds when sync_mode='debounce'.")
|
|
242
|
+
|
|
243
|
+
default_edge_options = param.Dict(default={}, doc="Default React Flow edge options.")
|
|
244
|
+
|
|
245
|
+
editable = param.Boolean(default=True, doc="Enable interactive editing on the canvas.")
|
|
246
|
+
|
|
247
|
+
editor_mode = param.ObjectSelector(
|
|
248
|
+
default="toolbar",
|
|
249
|
+
objects=["toolbar", "node", "side"],
|
|
250
|
+
doc="Where to render node editors: toolbar, node, or side panel.",
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
enable_connect = param.Boolean(default=True, doc="Allow connecting nodes to create edges.")
|
|
254
|
+
|
|
255
|
+
enable_delete = param.Boolean(default=True, doc="Allow deleting selected nodes or edges.")
|
|
256
|
+
|
|
257
|
+
enable_multiselect = param.Boolean(default=True, doc="Allow multiselect with modifier key.")
|
|
258
|
+
|
|
259
|
+
selection = param.Dict(default={"nodes": [], "edges": []}, doc="Derived selection state for node and edge ids.")
|
|
260
|
+
|
|
261
|
+
show_minimap = param.Boolean(default=True, doc="Show the minimap overlay.")
|
|
262
|
+
|
|
263
|
+
sync_mode = param.ObjectSelector(default="event", objects=["event", "debounce"], doc="Sync mode for JS->Python updates.")
|
|
264
|
+
|
|
265
|
+
viewport = param.Dict(default=None, allow_None=True, doc="Optional persisted viewport state.")
|
|
266
|
+
|
|
267
|
+
top_panel = Children(default=[], doc="Children rendered in a top-center panel.")
|
|
268
|
+
bottom_panel = Children(default=[], doc="Children rendered in a bottom-center panel.")
|
|
269
|
+
left_panel = Children(default=[], doc="Children rendered in a center-left panel.")
|
|
270
|
+
right_panel = Children(default=[], doc="Children rendered in a center-right panel.")
|
|
271
|
+
|
|
272
|
+
# Internal view parameters
|
|
273
|
+
_node_editors = param.Dict(default={}, doc="Per-node editors for node mode.", precedence=-1)
|
|
274
|
+
_node_editor_views = Children(default=[], doc="Toolbar content rendered inside NodeToolbar.")
|
|
275
|
+
_views = Children(default=[], doc="Panel viewables rendered inside nodes via view_idx.")
|
|
276
|
+
|
|
277
|
+
_bundle = DIST_PATH / "panel-reactflow.bundle.js"
|
|
278
|
+
_esm = Path(__file__).parent / "models" / "reactflow.jsx"
|
|
279
|
+
_importmap = {"imports": {"@xyflow/react": "https://esm.sh/@xyflow/react@12.8.3"}}
|
|
280
|
+
_stylesheets = [DIST_PATH / "panel-reactflow.bundle.css", DIST_PATH / "css" / "reactflow.css"]
|
|
281
|
+
|
|
282
|
+
def __init__(self, **params: Any):
|
|
283
|
+
self._node_ids = []
|
|
284
|
+
super().__init__(**params)
|
|
285
|
+
self._editor = None
|
|
286
|
+
self._node_watchers = {}
|
|
287
|
+
self._event_handlers: dict[str, list[Callable]] = {"*": []}
|
|
288
|
+
self.param.watch(self._update_selection_from_graph, ["nodes", "edges"])
|
|
289
|
+
self.param.watch(self._normalize_specs, ["node_types", "edge_types"])
|
|
290
|
+
self.param.watch(self._update_node_editors, ["nodes", "editor_mode", "selection"])
|
|
291
|
+
self._update_node_editors()
|
|
292
|
+
|
|
293
|
+
@classmethod
|
|
294
|
+
def _esm_path(cls, compiled: bool | Literal["compiling"] = True) -> os.PathLike | None:
|
|
295
|
+
return super()._esm_path(compiled or True)
|
|
296
|
+
|
|
297
|
+
@classmethod
|
|
298
|
+
def _render_esm(cls, compiled: bool | Literal["compiling"] = True, server: bool = False):
|
|
299
|
+
esm_path = cls._esm_path(compiled=compiled)
|
|
300
|
+
if compiled != "compiling" and server:
|
|
301
|
+
# Generate relative path to handle apps served on subpaths
|
|
302
|
+
esm = ("" if state.rel_path else "./") + cls._component_resource_path(esm_path, compiled)
|
|
303
|
+
if config.autoreload:
|
|
304
|
+
modified = hashlib.sha256(str(esm_path.stat().st_mtime).encode("utf-8")).hexdigest()
|
|
305
|
+
esm += f"?{modified}"
|
|
306
|
+
else:
|
|
307
|
+
esm = esm_path.read_text(encoding="utf-8")
|
|
308
|
+
return esm
|
|
309
|
+
|
|
310
|
+
@classproperty
|
|
311
|
+
def _bundle_path(cls) -> os.PathLike | None:
|
|
312
|
+
return cls._bundle
|
|
313
|
+
|
|
314
|
+
def _apply_node_editor_changes(self, event: param.parameterized.Event) -> None:
|
|
315
|
+
self.patch_node_data(event.obj.id, event.new)
|
|
316
|
+
|
|
317
|
+
def _update_node_editors(self, *events: tuple[param.parameterized.Event]) -> None:
|
|
318
|
+
node_ids = [node["id"] for node in self.nodes]
|
|
319
|
+
edit_changed = any(event.name == "editor_mode" for event in events)
|
|
320
|
+
if node_ids == self._node_ids and not edit_changed:
|
|
321
|
+
return
|
|
322
|
+
|
|
323
|
+
# Unwatch old editors
|
|
324
|
+
for node_id in set(self._node_editors.keys()) - set(node_ids):
|
|
325
|
+
watcher = self._node_watchers[node_id]
|
|
326
|
+
editor = self._node_editors[node_id]
|
|
327
|
+
editor.param.unwatch(watcher)
|
|
328
|
+
self._node_ids = node_ids
|
|
329
|
+
|
|
330
|
+
# Construct new editors
|
|
331
|
+
editors = {}
|
|
332
|
+
for node in self.nodes:
|
|
333
|
+
node_id = node.get("id")
|
|
334
|
+
if node_id in self._node_editors:
|
|
335
|
+
editors[node_id] = self._node_editors[node_id]
|
|
336
|
+
continue
|
|
337
|
+
editor_cls = self.node_types.get(node.get("type", "panel"), JsonNodeEditor)
|
|
338
|
+
editors[node_id] = editor = editor_cls(_id=node_id, _data=node.get("data", {}))
|
|
339
|
+
self._node_watchers[node_id] = editor.param.watch(self._apply_node_editor_changes, "_data")
|
|
340
|
+
self._node_editors = editors
|
|
341
|
+
self.param.trigger("_node_editor_views")
|
|
342
|
+
|
|
343
|
+
def _apply_toolbar_changes(self, event: param.parameterized.Event) -> None:
|
|
344
|
+
self.patch_node_data(event.obj.id, event.new)
|
|
345
|
+
|
|
346
|
+
def _get_children(self, data_model, doc, root, parent, comm) -> tuple[dict[str, list[UIElement] | UIElement | None], list[UIElement]]:
|
|
347
|
+
views = []
|
|
348
|
+
editors = []
|
|
349
|
+
for node in self.nodes:
|
|
350
|
+
view = node.get("view", None)
|
|
351
|
+
if view is not None:
|
|
352
|
+
views.append(view)
|
|
353
|
+
editor = self._node_editors.get(node.get("id"))
|
|
354
|
+
editors.append(editor.__panel__())
|
|
355
|
+
children: dict[str, list[UIElement] | UIElement | None] = {}
|
|
356
|
+
old_models: list[UIElement] = []
|
|
357
|
+
if views:
|
|
358
|
+
views, view_models = self._get_child_model(views, doc, root, parent, comm)
|
|
359
|
+
children["_views"] = views
|
|
360
|
+
old_models += view_models
|
|
361
|
+
if editors:
|
|
362
|
+
editor_models, editor_old = self._get_child_model(editors, doc, root, parent, comm)
|
|
363
|
+
children["_node_editor_views"] = editor_models
|
|
364
|
+
old_models += editor_old
|
|
365
|
+
for name in ("top_panel", "bottom_panel", "left_panel", "right_panel"):
|
|
366
|
+
panels = list(getattr(self, name, []) or [])
|
|
367
|
+
if panels:
|
|
368
|
+
panel_models, panel_old = self._get_child_model(panels, doc, root, parent, comm)
|
|
369
|
+
children[name] = panel_models
|
|
370
|
+
old_models += panel_old
|
|
371
|
+
else:
|
|
372
|
+
children[name] = []
|
|
373
|
+
return children, old_models
|
|
374
|
+
|
|
375
|
+
def _process_param_change(self, params):
|
|
376
|
+
params = super()._process_param_change(params)
|
|
377
|
+
if "nodes" in params:
|
|
378
|
+
nodes = []
|
|
379
|
+
view_idx = 0
|
|
380
|
+
for node in params["nodes"]:
|
|
381
|
+
node = dict(node)
|
|
382
|
+
view = node.pop("view", None)
|
|
383
|
+
data = dict(node.get("data", {}))
|
|
384
|
+
if view is not None:
|
|
385
|
+
data["view_idx"] = view_idx
|
|
386
|
+
view_idx += 1
|
|
387
|
+
node["data"] = data
|
|
388
|
+
nodes.append(node)
|
|
389
|
+
params["nodes"] = nodes
|
|
390
|
+
params.pop("node_types", None)
|
|
391
|
+
params.pop("edge_types", None)
|
|
392
|
+
params.pop("_node_editors", None)
|
|
393
|
+
return params
|
|
394
|
+
|
|
395
|
+
def add_node(self, node: dict[str, Any] | NodeSpec, *, view: Any | None = None) -> None:
|
|
396
|
+
"""Add a node to the graph.
|
|
397
|
+
|
|
398
|
+
Parameters
|
|
399
|
+
----------
|
|
400
|
+
node:
|
|
401
|
+
Node dictionary or ``NodeSpec`` instance to add.
|
|
402
|
+
view:
|
|
403
|
+
Optional Panel viewable rendered inside the node. If provided,
|
|
404
|
+
``view`` is attached to the node and transformed into ``view_idx``.
|
|
405
|
+
"""
|
|
406
|
+
payload = self._coerce_node(node)
|
|
407
|
+
payload.setdefault("type", "panel")
|
|
408
|
+
payload.setdefault("data", {})
|
|
409
|
+
payload.setdefault("position", {"x": 0.0, "y": 0.0})
|
|
410
|
+
self._validate_graph_payload(payload, kind="node")
|
|
411
|
+
self.nodes = self.nodes + [dict(payload, view=view)]
|
|
412
|
+
self._emit("node_added", {"type": "node_added", "node": payload})
|
|
413
|
+
|
|
414
|
+
def _handle_msg(self, msg: dict[str, Any]) -> None:
|
|
415
|
+
"""Handle sync messages from the frontend."""
|
|
416
|
+
if not isinstance(msg, dict):
|
|
417
|
+
return
|
|
418
|
+
match msg.get("type"):
|
|
419
|
+
case "sync":
|
|
420
|
+
nodes = msg.get("nodes")
|
|
421
|
+
edges = msg.get("edges")
|
|
422
|
+
if nodes is not None:
|
|
423
|
+
self.nodes = nodes
|
|
424
|
+
if edges is not None:
|
|
425
|
+
self.edges = edges
|
|
426
|
+
self._emit("sync", msg)
|
|
427
|
+
case "node_moved":
|
|
428
|
+
node_id = msg.get("node_id")
|
|
429
|
+
position = msg.get("position")
|
|
430
|
+
if node_id is None or position is None:
|
|
431
|
+
return
|
|
432
|
+
for node in self.nodes:
|
|
433
|
+
if node.get("id") == node_id:
|
|
434
|
+
node["position"] = position
|
|
435
|
+
self._emit("node_moved", msg)
|
|
436
|
+
case "selection_changed":
|
|
437
|
+
node_ids = msg.get("nodes") or []
|
|
438
|
+
edge_ids = msg.get("edges") or []
|
|
439
|
+
for node in self.nodes:
|
|
440
|
+
node["selected"] = node.get("id") in node_ids
|
|
441
|
+
for edge in self.edges:
|
|
442
|
+
edge["selected"] = edge.get("id") in edge_ids
|
|
443
|
+
self.selection = {"nodes": list(node_ids), "edges": list(edge_ids)}
|
|
444
|
+
self._emit("selection_changed", msg)
|
|
445
|
+
case "edge_added":
|
|
446
|
+
edge = msg.get("edge")
|
|
447
|
+
if edge is None:
|
|
448
|
+
return
|
|
449
|
+
self.add_edge(edge)
|
|
450
|
+
self._emit("edge_added", msg)
|
|
451
|
+
case "node_deleted":
|
|
452
|
+
node_ids = msg.get("node_ids") or []
|
|
453
|
+
if msg.get("node_id"):
|
|
454
|
+
node_ids = list(set(node_ids) | {msg.get("node_id")})
|
|
455
|
+
for node_id in node_ids:
|
|
456
|
+
self.remove_node(node_id)
|
|
457
|
+
self._emit("node_deleted", msg)
|
|
458
|
+
case "edge_deleted":
|
|
459
|
+
edge_ids = msg.get("edge_ids") or []
|
|
460
|
+
if msg.get("edge_id"):
|
|
461
|
+
edge_ids = list(set(edge_ids) | {msg.get("edge_id")})
|
|
462
|
+
for edge_id in edge_ids:
|
|
463
|
+
self.remove_edge(edge_id)
|
|
464
|
+
self._emit("edge_deleted", msg)
|
|
465
|
+
case "node_clicked":
|
|
466
|
+
node_id = msg.get("node_id")
|
|
467
|
+
if node_id is None:
|
|
468
|
+
return
|
|
469
|
+
self._build_toolbar_for_node(node_id)
|
|
470
|
+
self._emit("node_clicked", msg)
|
|
471
|
+
case _:
|
|
472
|
+
return
|
|
473
|
+
|
|
474
|
+
def remove_node(self, node_id: str) -> None:
|
|
475
|
+
"""Remove a node and any connected edges.
|
|
476
|
+
|
|
477
|
+
Parameters
|
|
478
|
+
----------
|
|
479
|
+
node_id:
|
|
480
|
+
Identifier of the node to remove.
|
|
481
|
+
"""
|
|
482
|
+
nodes = [node for node in self.nodes if node.get("id") != node_id]
|
|
483
|
+
removed_edges = [edge for edge in self.edges if edge.get("source") == node_id or edge.get("target") == node_id]
|
|
484
|
+
self.nodes = nodes
|
|
485
|
+
if removed_edges:
|
|
486
|
+
remaining_edges = [edge for edge in self.edges if edge not in removed_edges]
|
|
487
|
+
self.edges = remaining_edges
|
|
488
|
+
self._emit(
|
|
489
|
+
"node_deleted",
|
|
490
|
+
{
|
|
491
|
+
"type": "node_deleted",
|
|
492
|
+
"node_id": node_id,
|
|
493
|
+
"deleted_edges": [edge.get("id") for edge in removed_edges],
|
|
494
|
+
},
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
def add_edge(self, edge: dict[str, Any] | EdgeSpec) -> None:
|
|
498
|
+
"""Add an edge to the graph.
|
|
499
|
+
|
|
500
|
+
Parameters
|
|
501
|
+
----------
|
|
502
|
+
edge:
|
|
503
|
+
Edge dictionary or ``EdgeSpec`` instance to add.
|
|
504
|
+
"""
|
|
505
|
+
payload = self._coerce_edge(edge)
|
|
506
|
+
payload.setdefault("data", {})
|
|
507
|
+
if not payload.get("id"):
|
|
508
|
+
payload["id"] = self._generate_edge_id(payload["source"], payload["target"])
|
|
509
|
+
self._validate_graph_payload(payload, kind="edge")
|
|
510
|
+
self.edges = self.edges + [payload]
|
|
511
|
+
self._emit("edge_added", {"type": "edge_added", "edge": payload})
|
|
512
|
+
|
|
513
|
+
def remove_edge(self, edge_id: str) -> None:
|
|
514
|
+
"""Remove an edge by id.
|
|
515
|
+
|
|
516
|
+
Parameters
|
|
517
|
+
----------
|
|
518
|
+
edge_id:
|
|
519
|
+
Identifier of the edge to remove.
|
|
520
|
+
"""
|
|
521
|
+
removed = [edge for edge in self.edges if edge.get("id") == edge_id]
|
|
522
|
+
self.edges = [edge for edge in self.edges if edge.get("id") != edge_id]
|
|
523
|
+
if removed:
|
|
524
|
+
self._emit("edge_deleted", {"type": "edge_deleted", "edge_id": edge_id})
|
|
525
|
+
|
|
526
|
+
def patch_node_data(self, node_id: str, patch: dict[str, Any]) -> None:
|
|
527
|
+
"""Patch the ``data`` dict for a node.
|
|
528
|
+
|
|
529
|
+
Parameters
|
|
530
|
+
----------
|
|
531
|
+
node_id:
|
|
532
|
+
Identifier of the node to update.
|
|
533
|
+
patch:
|
|
534
|
+
Dictionary of key/value pairs merged into ``node["data"]``.
|
|
535
|
+
"""
|
|
536
|
+
for node in self.nodes:
|
|
537
|
+
if node.get("id") == node_id:
|
|
538
|
+
data = dict(node.get("data", {}))
|
|
539
|
+
data.update(patch)
|
|
540
|
+
node["data"] = data
|
|
541
|
+
break
|
|
542
|
+
self._send_msg({"type": "patch_node_data", "node_id": node_id, "patch": patch})
|
|
543
|
+
self._emit("node_data_changed", {"type": "node_data_changed", "node_id": node_id, "patch": patch})
|
|
544
|
+
|
|
545
|
+
def patch_edge_data(self, edge_id: str, patch: dict[str, Any]) -> None:
|
|
546
|
+
"""Patch the ``data`` dict for an edge.
|
|
547
|
+
|
|
548
|
+
Parameters
|
|
549
|
+
----------
|
|
550
|
+
edge_id:
|
|
551
|
+
Identifier of the edge to update.
|
|
552
|
+
patch:
|
|
553
|
+
Dictionary of key/value pairs merged into ``edge["data"]``.
|
|
554
|
+
"""
|
|
555
|
+
for edge in self.edges:
|
|
556
|
+
if edge.get("id") == edge_id:
|
|
557
|
+
data = dict(edge.get("data", {}))
|
|
558
|
+
data.update(patch)
|
|
559
|
+
edge["data"] = data
|
|
560
|
+
break
|
|
561
|
+
self._send_msg({"type": "patch_edge_data", "edge_id": edge_id, "patch": patch})
|
|
562
|
+
self._emit("edge_data_changed", {"type": "edge_data_changed", "edge_id": edge_id, "patch": patch})
|
|
563
|
+
|
|
564
|
+
def to_networkx(self, *, multigraph: bool = False):
|
|
565
|
+
"""Convert the current graph state to a NetworkX graph.
|
|
566
|
+
|
|
567
|
+
Parameters
|
|
568
|
+
----------
|
|
569
|
+
multigraph:
|
|
570
|
+
Whether to return a ``MultiDiGraph`` instead of a ``DiGraph``.
|
|
571
|
+
|
|
572
|
+
Returns
|
|
573
|
+
-------
|
|
574
|
+
networkx.Graph
|
|
575
|
+
NetworkX representation of the graph.
|
|
576
|
+
"""
|
|
577
|
+
try:
|
|
578
|
+
import networkx as nx # type: ignore[import-not-found]
|
|
579
|
+
except Exception as exc: # pragma: no cover
|
|
580
|
+
raise ImportError("networkx is required for to_networkx.") from exc
|
|
581
|
+
|
|
582
|
+
graph = nx.MultiDiGraph() if multigraph else nx.DiGraph()
|
|
583
|
+
for node in self.nodes:
|
|
584
|
+
data = dict(node.get("data", {}))
|
|
585
|
+
data.update({"position": node.get("position"), "type": node.get("type")})
|
|
586
|
+
graph.add_node(node["id"], **data)
|
|
587
|
+
for edge in self.edges:
|
|
588
|
+
data = dict(edge.get("data", {}))
|
|
589
|
+
data.update({"label": edge.get("label"), "type": edge.get("type")})
|
|
590
|
+
graph.add_edge(edge["source"], edge["target"], key=edge.get("id"), **data)
|
|
591
|
+
return graph
|
|
592
|
+
|
|
593
|
+
@classmethod
|
|
594
|
+
def from_networkx(
|
|
595
|
+
cls,
|
|
596
|
+
graph,
|
|
597
|
+
*,
|
|
598
|
+
node_type: str = "panel",
|
|
599
|
+
default_position: tuple[float, float] = (0.0, 0.0),
|
|
600
|
+
) -> "ReactFlow":
|
|
601
|
+
"""Create a ReactFlow instance from a NetworkX graph.
|
|
602
|
+
|
|
603
|
+
Parameters
|
|
604
|
+
----------
|
|
605
|
+
graph:
|
|
606
|
+
A NetworkX graph instance.
|
|
607
|
+
node_type:
|
|
608
|
+
Default node type assigned to nodes.
|
|
609
|
+
default_position:
|
|
610
|
+
Default (x, y) position when none is provided in attributes.
|
|
611
|
+
|
|
612
|
+
Returns
|
|
613
|
+
-------
|
|
614
|
+
ReactFlow
|
|
615
|
+
ReactFlow instance populated with nodes and edges.
|
|
616
|
+
"""
|
|
617
|
+
nodes: list[dict[str, Any]] = []
|
|
618
|
+
edges: list[dict[str, Any]] = []
|
|
619
|
+
for node_id, attrs in graph.nodes(data=True):
|
|
620
|
+
position = attrs.pop("position", {"x": default_position[0], "y": default_position[1]})
|
|
621
|
+
if isinstance(position, (tuple, list)):
|
|
622
|
+
position = {"x": position[0], "y": position[1]}
|
|
623
|
+
node_data = dict(attrs)
|
|
624
|
+
node_data.pop("type", None)
|
|
625
|
+
embedded_data = node_data.pop("data", None)
|
|
626
|
+
if isinstance(embedded_data, dict):
|
|
627
|
+
node_data = {**embedded_data, **node_data}
|
|
628
|
+
nodes.append({"id": str(node_id), "position": position, "type": node_type, "data": node_data})
|
|
629
|
+
if graph.is_multigraph():
|
|
630
|
+
edge_iter = graph.edges(keys=True, data=True)
|
|
631
|
+
else:
|
|
632
|
+
edge_iter = ((source, target, None, attrs) for source, target, attrs in graph.edges(data=True))
|
|
633
|
+
for source, target, key, attrs in edge_iter:
|
|
634
|
+
edge_data = dict(attrs)
|
|
635
|
+
embedded_edge_data = edge_data.pop("data", None)
|
|
636
|
+
if isinstance(embedded_edge_data, dict):
|
|
637
|
+
edge_data = {**embedded_edge_data, **edge_data}
|
|
638
|
+
label = edge_data.pop("label", None)
|
|
639
|
+
edge_type = edge_data.pop("type", None)
|
|
640
|
+
edge_id = key if key is not None else f"{source}->{target}"
|
|
641
|
+
edge = {
|
|
642
|
+
"id": str(edge_id),
|
|
643
|
+
"source": str(source),
|
|
644
|
+
"target": str(target),
|
|
645
|
+
"data": edge_data,
|
|
646
|
+
}
|
|
647
|
+
if label is not None:
|
|
648
|
+
edge["label"] = label
|
|
649
|
+
if edge_type is not None:
|
|
650
|
+
edge["type"] = edge_type
|
|
651
|
+
edges.append(edge)
|
|
652
|
+
return cls(nodes=nodes, edges=edges)
|
|
653
|
+
|
|
654
|
+
def on(self, event_type: str, callback) -> None:
|
|
655
|
+
"""Register a Python callback for frontend events.
|
|
656
|
+
|
|
657
|
+
Parameters
|
|
658
|
+
----------
|
|
659
|
+
event_type:
|
|
660
|
+
Event name to listen for (e.g. ``node_moved``). Use ``*`` for all events.
|
|
661
|
+
callback:
|
|
662
|
+
Callable invoked with the event payload.
|
|
663
|
+
"""
|
|
664
|
+
self._event_handlers.setdefault(event_type, []).append(callback)
|
|
665
|
+
|
|
666
|
+
def _emit(self, event_type: str, payload: dict[str, Any]) -> None:
|
|
667
|
+
for callback in self._event_handlers.get(event_type, []):
|
|
668
|
+
callback(payload)
|
|
669
|
+
for callback in self._event_handlers.get("*", []):
|
|
670
|
+
callback(payload)
|
|
671
|
+
|
|
672
|
+
def _update_selection_from_graph(self, *_: param.parameterized.Event) -> None:
|
|
673
|
+
selection = {
|
|
674
|
+
"nodes": [node["id"] for node in self.nodes if node.get("selected")],
|
|
675
|
+
"edges": [edge["id"] for edge in self.edges if edge.get("selected")],
|
|
676
|
+
}
|
|
677
|
+
if selection != self.selection:
|
|
678
|
+
self.selection = selection
|
|
679
|
+
self._emit(
|
|
680
|
+
"selection_changed",
|
|
681
|
+
{"type": "selection_changed", "nodes": selection["nodes"], "edges": selection["edges"]},
|
|
682
|
+
)
|
|
683
|
+
|
|
684
|
+
def _normalize_specs(self, event: param.parameterized.Event) -> None:
|
|
685
|
+
normalized = _coerce_spec_map(event.new)
|
|
686
|
+
if normalized != event.new:
|
|
687
|
+
setattr(self, event.name, normalized)
|
|
688
|
+
|
|
689
|
+
@staticmethod
|
|
690
|
+
def _generate_edge_id(source: str, target: str) -> str:
|
|
691
|
+
existing = f"{source}->{target}"
|
|
692
|
+
return f"{existing}-{uuid4().hex[:8]}"
|
|
693
|
+
|
|
694
|
+
@staticmethod
|
|
695
|
+
def _coerce_node(node: dict[str, Any] | NodeSpec) -> dict[str, Any]:
|
|
696
|
+
return node.to_dict() if hasattr(node, "to_dict") else dict(node)
|
|
697
|
+
|
|
698
|
+
@staticmethod
|
|
699
|
+
def _coerce_edge(edge: dict[str, Any] | EdgeSpec) -> dict[str, Any]:
|
|
700
|
+
return edge.to_dict() if hasattr(edge, "to_dict") else dict(edge)
|
|
701
|
+
|
|
702
|
+
def _validate_graph_payload(self, payload: dict[str, Any], *, kind: str) -> None:
|
|
703
|
+
required = {"node": ["id", "position", "data"], "edge": ["id", "source", "target"]}[kind]
|
|
704
|
+
for key in required:
|
|
705
|
+
if key not in payload:
|
|
706
|
+
raise ValueError(f"Missing '{key}' in {kind} payload.")
|