Skip to content

Commit 0a3e354

Browse files
authored
Allow creating graphs with parameterized Node and Edge instances (#54)
* Allow creating graphs with parameterized Node and Edge instances * Add instance example of threejs
1 parent 08d6a62 commit 0a3e354

8 files changed

Lines changed: 1720 additions & 101 deletions

File tree

docs/how-to/define-nodes-edges.md

Lines changed: 110 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22

33
Every graph in Panel-ReactFlow is built from two lists: **nodes** and
44
**edges**. Nodes represent entities on the canvas; edges represent
5-
connections between them. Both are plain Python dictionaries, so you can
6-
construct them from any data source — a database, a config file, or user
7-
input at runtime.
5+
connections between them. Nodes can be plain dictionaries, `NodeSpec`
6+
objects, or `Node` instances, so you can choose between lightweight payloads
7+
and object-oriented node classes.
88

99
This guide covers how to create nodes and edges, use the helper dataclasses,
1010
and update data after the graph is live.
@@ -104,6 +104,40 @@ nodes = [
104104

105105
---
106106

107+
## Define nodes as classes
108+
109+
Use `Node` when you want per-node Python state, event hooks, and optional
110+
custom view/editor methods.
111+
112+
```python
113+
import panel as pn
114+
from panel_reactflow import Node, ReactFlow
115+
116+
117+
class JobNode(Node):
118+
def __init__(self, **params):
119+
super().__init__(type="job", data={"status": "idle"}, **params)
120+
121+
def __panel__(self):
122+
return pn.pane.Markdown(f"**{self.label}**: {self.data.get('status')}")
123+
124+
def on_move(self, payload, flow):
125+
print(f"{self.id} moved to {payload['position']}")
126+
127+
128+
nodes = [
129+
JobNode(id="j1", label="Fetch", position={"x": 0, "y": 0}),
130+
JobNode(id="j2", label="Process", position={"x": 260, "y": 60}),
131+
]
132+
133+
flow = ReactFlow(nodes=nodes)
134+
```
135+
136+
`Node` instances stay as Python objects in `flow.nodes`; they are serialized
137+
to dicts only when syncing to the frontend.
138+
139+
---
140+
107141
## Define edges
108142

109143
Edges link two nodes by their `id`. Use the top-level `label` for the
@@ -128,6 +162,79 @@ edges = [
128162

129163
---
130164

165+
## Define edges as classes
166+
167+
Use `Edge` when you want object-oriented edge state and edge-specific hooks or
168+
editor logic.
169+
170+
```python
171+
from panel_reactflow import Edge, ReactFlow
172+
173+
174+
class FlowEdge(Edge):
175+
def __init__(self, **params):
176+
super().__init__(type="flow", data={"weight": 1.0}, **params)
177+
178+
def on_data_change(self, payload, flow):
179+
print(f"{self.id} updated:", payload["patch"])
180+
181+
182+
flow = ReactFlow(
183+
nodes=[
184+
{"id": "n1", "position": {"x": 0, "y": 0}, "data": {}},
185+
{"id": "n2", "position": {"x": 260, "y": 60}, "data": {}},
186+
],
187+
edges=[FlowEdge(id="e1", source="n1", target="n2")],
188+
)
189+
```
190+
191+
`Edge` instances stay as Python objects in `flow.edges`; they are serialized
192+
to dicts only when syncing to the frontend.
193+
194+
---
195+
196+
## Data <-> parameter sync on `Node` and `Edge`
197+
198+
For class-based nodes/edges, Panel-ReactFlow supports two-way synchronization
199+
between `data` and declared parameters.
200+
201+
### Which parameters are included?
202+
203+
Only subclass parameters with **explicit non-negative precedence**
204+
(`precedence >= 0`) are treated as data fields.
205+
206+
```python
207+
import param
208+
from panel_reactflow import Node
209+
210+
211+
class TaskNode(Node):
212+
status = param.Selector(default="idle", objects=["idle", "running", "done"], precedence=0)
213+
retries = param.Integer(default=0, precedence=0)
214+
_internal_state = param.String(default="x", precedence=-1)
215+
```
216+
217+
In this example:
218+
219+
- `status` and `retries` are included in `data`
220+
- `_internal_state` is not included
221+
222+
### Sync behavior
223+
224+
- **Parameter -> data**: updating `node.status` or `edge.weight` triggers an
225+
automatic data patch to the graph and frontend.
226+
- **Data -> parameter**: incoming graph patches/sync updates write values back
227+
onto matching parameters.
228+
- **Schema generation**: if no explicit type schema is provided, these
229+
included parameters are used to generate a JSON schema for editors.
230+
231+
### Editor implication
232+
233+
If your editor widgets are bound with `from_param(...)`, you usually do not
234+
need manual `on_patch` watchers for those data parameters.
235+
236+
---
237+
131238
## Use the NodeSpec / EdgeSpec helpers
132239

133240
If you prefer a typed API, use the dataclass helpers. They validate fields

docs/how-to/react-to-events.md

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,10 @@ the `ReactFlow` instance as a second argument. You can also listen for
2323
| `node_deleted` | A node is removed. | `node_id` |
2424
| `node_moved` | A node is dragged to a new position. | `node_id`, `position` |
2525
| `node_clicked` | A node is clicked (single click). | `node_id` |
26-
| `node_data_changed` | `patch_node_data()` is called. | `node_id`, `patch` |
26+
| `node_data_changed` | Node data is patched (via API, editor patch, or parameter-driven sync). | `node_id`, `patch` |
2727
| `edge_added` | An edge is created (UI connect or API). | `edge` |
2828
| `edge_deleted` | An edge is removed. | `edge_id` |
29-
| `edge_data_changed` | `patch_edge_data()` is called. | `edge_id`, `patch` |
29+
| `edge_data_changed` | Edge data is patched (via API, editor patch, or parameter-driven sync). | `edge_id`, `patch` |
3030
| `selection_changed` | The active selection changes. | `nodes`, `edges` |
3131
| `sync` | A batch sync from the frontend. | *(varies)* |
3232

@@ -57,6 +57,58 @@ pn.Column(log, flow).servable()
5757

5858
---
5959

60+
## Handle events on `Node` classes
61+
62+
If you define nodes as `Node` subclasses, you can implement hooks directly on
63+
the node instance:
64+
65+
```python
66+
from panel_reactflow import Node, ReactFlow
67+
68+
69+
class TaskNode(Node):
70+
def on_event(self, payload, flow):
71+
print("any node event:", payload["type"])
72+
73+
def on_delete(self, payload, flow):
74+
print("deleted:", self.id)
75+
76+
77+
flow = ReactFlow(nodes=[TaskNode(id="t1", position={"x": 0, "y": 0}, data={})])
78+
```
79+
80+
Common hooks include `on_event` (wildcard), `on_add`, `on_move`, `on_click`,
81+
`on_data_change`, and `on_delete`.
82+
83+
When a `Node` subclass parameter with `precedence >= 0` changes, it
84+
automatically patches node data and will trigger `on_data_change`.
85+
86+
---
87+
88+
## Handle events on `Edge` classes
89+
90+
`Edge` subclasses can handle edge lifecycle and patch events directly:
91+
92+
```python
93+
from panel_reactflow import Edge, ReactFlow
94+
95+
96+
class WeightedEdge(Edge):
97+
def on_data_change(self, payload, flow):
98+
print("edge patch:", payload["patch"])
99+
100+
def on_delete(self, payload, flow):
101+
print("edge deleted:", self.id)
102+
```
103+
104+
Common edge hooks include `on_event`, `on_add`, `on_data_change`,
105+
`on_selection_changed`, and `on_delete`.
106+
107+
Likewise, changing an `Edge` subclass data parameter (`precedence >= 0`)
108+
triggers `on_data_change` through the same data patch pipeline.
109+
110+
---
111+
60112
## Listen for all events
61113

62114
Use the wildcard `"*"` to receive every event. This is useful for

examples/node_edge_instances.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
"""Complex example using Node and Edge class instances.
2+
3+
Demonstrates:
4+
- ``Node`` / ``Edge`` subclass instances in ``ReactFlow``
5+
- Per-instance ``__panel__`` node views
6+
- Per-instance custom editors via ``editor(...)``
7+
- Node/edge event hooks (``on_data_change``, ``on_selection_changed``)
8+
- Programmatic updates with ``patch_node_data`` / ``patch_edge_data``
9+
"""
10+
11+
import random
12+
13+
import panel as pn
14+
import panel_material_ui as pmui
15+
import param
16+
17+
from panel_reactflow import Edge, Node, ReactFlow
18+
19+
pn.extension()
20+
21+
22+
class PipelineNode(Node):
23+
status = param.Selector(default="idle", objects=["idle", "running", "done", "failed"], precedence=0)
24+
retries = param.Integer(default=0, bounds=(0, None), precedence=0)
25+
owner = param.String(default="ops", precedence=0)
26+
notes = param.String(default="", precedence=0)
27+
28+
def __init__(self, **params):
29+
params.setdefault("type", "pipeline")
30+
super().__init__(**params)
31+
self._summary = pn.pane.Markdown(margin=(0, 0, 6, 0))
32+
self._activity = pn.pane.Markdown("", styles={"font-size": "12px", "opacity": "0.8"})
33+
self.param.watch(self._refresh_view, ["status", "owner", "retries", "label"])
34+
self._refresh_view()
35+
36+
def _refresh_view(self, *_):
37+
self._summary.object = (
38+
f"**{self.label}** \n"
39+
f"Status: `{self.status}` \n"
40+
f"Owner: `{self.owner}` \n"
41+
f"Retries: `{self.retries}`"
42+
)
43+
44+
def __panel__(self):
45+
return pn.Column(self._summary, self._activity, margin=0, sizing_mode="stretch_width")
46+
47+
def editor(self, data, schema, *, id, type, on_patch):
48+
status = pmui.Select.from_param(self.param.status, name="Status")
49+
retries = pmui.IntInput.from_param(self.param.retries, name="Retries")
50+
owner = pmui.TextInput.from_param(self.param.owner, name="Owner")
51+
notes = pmui.TextAreaInput.from_param(self.param.notes, name="Notes", height=80)
52+
return pn.Column(status, retries, owner, notes, sizing_mode="stretch_width")
53+
54+
def on_data_change(self, payload, flow):
55+
if payload.get("node_id") == self.id:
56+
self._activity.object = f"Last patch: `{payload.get('patch', {})}`"
57+
58+
def on_selection_changed(self, payload, flow):
59+
selected = self.id in (payload.get("nodes") or [])
60+
if selected:
61+
self._activity.object = "Selected in canvas"
62+
63+
64+
class WeightedEdge(Edge):
65+
weight = param.Number(default=0.5, bounds=(0, 1), precedence=0)
66+
channel = param.Selector(default="main", objects=["main", "backup", "shadow"], precedence=0)
67+
enabled = param.Boolean(default=True, precedence=0)
68+
69+
def __init__(self, **params):
70+
params.setdefault("type", "weighted")
71+
super().__init__(**params)
72+
73+
def editor(self, data, schema, *, id, type, on_patch):
74+
weight = pmui.FloatSlider.from_param(self.param.weight, name="Weight", step=0.01)
75+
channel = pmui.Select.from_param(self.param.channel, name="Channel")
76+
enabled = pmui.Checkbox.from_param(self.param.enabled, name="Enabled")
77+
return pn.Column(weight, channel, enabled, sizing_mode="stretch_width")
78+
79+
80+
nodes = [
81+
PipelineNode(id="extract", label="Extract", position={"x": 0, "y": 40}),
82+
PipelineNode(id="transform", label="Transform", position={"x": 300, "y": 160}, status="running", retries=1, owner="ml", notes="Batch window"),
83+
PipelineNode(id="load", label="Load", position={"x": 600, "y": 40}, owner="platform"),
84+
]
85+
86+
edges = [
87+
WeightedEdge(id="e1", source="extract", target="transform", weight=0.72),
88+
WeightedEdge(id="e2", source="transform", target="load", weight=0.63, channel="backup"),
89+
]
90+
91+
event_log = pmui.TextAreaInput(name="Events", value="", disabled=True, height=180, sizing_mode="stretch_width")
92+
last_event = pn.pane.Markdown("**Last event:** _none_")
93+
94+
flow = ReactFlow(
95+
nodes=nodes,
96+
edges=edges,
97+
editor_mode="side",
98+
sizing_mode="stretch_both",
99+
)
100+
101+
def _log_event(payload):
102+
event_type = payload.get("type", "unknown")
103+
last_event.object = f"**Last event:** `{event_type}`"
104+
snippet = str(payload)
105+
event_log.value = f"{event_log.value}\n{event_type}: {snippet}"[-6000:]
106+
107+
108+
flow.on("*", _log_event)
109+
110+
111+
def _advance_nodes(_):
112+
order = {"idle": "running", "running": "done", "done": "done", "failed": "idle"}
113+
for node in nodes:
114+
current = node.status
115+
flow.patch_node_data(node.id, {"status": order.get(current, "idle")})
116+
117+
118+
def _randomize_weights(_):
119+
for edge in edges:
120+
flow.patch_edge_data(edge.id, {"weight": round(random.uniform(0.05, 0.95), 2)})
121+
122+
123+
advance_btn = pmui.Button(name="Advance pipeline")
124+
advance_btn.on_click(_advance_nodes)
125+
126+
weights_btn = pmui.Button(name="Randomize edge weights")
127+
weights_btn.on_click(_randomize_weights)
128+
129+
controls = pn.Row(advance_btn, weights_btn, sizing_mode="stretch_width")
130+
131+
pn.Column(
132+
pn.pane.Markdown("## Node/Edge Instance Workflow"),
133+
controls,
134+
last_event,
135+
flow,
136+
event_log,
137+
sizing_mode="stretch_both",
138+
).servable()

0 commit comments

Comments
 (0)