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