Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 110 additions & 3 deletions docs/how-to/define-nodes-edges.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

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

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

---

## Define nodes as classes

Use `Node` when you want per-node Python state, event hooks, and optional
custom view/editor methods.

```python
import panel as pn
from panel_reactflow import Node, ReactFlow


class JobNode(Node):
def __init__(self, **params):
super().__init__(type="job", data={"status": "idle"}, **params)

def __panel__(self):
return pn.pane.Markdown(f"**{self.label}**: {self.data.get('status')}")

def on_move(self, payload, flow):
print(f"{self.id} moved to {payload['position']}")


nodes = [
JobNode(id="j1", label="Fetch", position={"x": 0, "y": 0}),
JobNode(id="j2", label="Process", position={"x": 260, "y": 60}),
]

flow = ReactFlow(nodes=nodes)
```

`Node` instances stay as Python objects in `flow.nodes`; they are serialized
to dicts only when syncing to the frontend.

---

## Define edges

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

---

## Define edges as classes

Use `Edge` when you want object-oriented edge state and edge-specific hooks or
editor logic.

```python
from panel_reactflow import Edge, ReactFlow


class FlowEdge(Edge):
def __init__(self, **params):
super().__init__(type="flow", data={"weight": 1.0}, **params)

def on_data_change(self, payload, flow):
print(f"{self.id} updated:", payload["patch"])


flow = ReactFlow(
nodes=[
{"id": "n1", "position": {"x": 0, "y": 0}, "data": {}},
{"id": "n2", "position": {"x": 260, "y": 60}, "data": {}},
],
edges=[FlowEdge(id="e1", source="n1", target="n2")],
)
```

`Edge` instances stay as Python objects in `flow.edges`; they are serialized
to dicts only when syncing to the frontend.

---

## Data <-> parameter sync on `Node` and `Edge`

For class-based nodes/edges, Panel-ReactFlow supports two-way synchronization
between `data` and declared parameters.

### Which parameters are included?

Only subclass parameters with **explicit non-negative precedence**
(`precedence >= 0`) are treated as data fields.

```python
import param
from panel_reactflow import Node


class TaskNode(Node):
status = param.Selector(default="idle", objects=["idle", "running", "done"], precedence=0)
retries = param.Integer(default=0, precedence=0)
_internal_state = param.String(default="x", precedence=-1)
```

In this example:

- `status` and `retries` are included in `data`
- `_internal_state` is not included

### Sync behavior

- **Parameter -> data**: updating `node.status` or `edge.weight` triggers an
automatic data patch to the graph and frontend.
- **Data -> parameter**: incoming graph patches/sync updates write values back
onto matching parameters.
- **Schema generation**: if no explicit type schema is provided, these
included parameters are used to generate a JSON schema for editors.

### Editor implication

If your editor widgets are bound with `from_param(...)`, you usually do not
need manual `on_patch` watchers for those data parameters.

---

## Use the NodeSpec / EdgeSpec helpers

If you prefer a typed API, use the dataclass helpers. They validate fields
Expand Down
56 changes: 54 additions & 2 deletions docs/how-to/react-to-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@ the `ReactFlow` instance as a second argument. You can also listen for
| `node_deleted` | A node is removed. | `node_id` |
| `node_moved` | A node is dragged to a new position. | `node_id`, `position` |
| `node_clicked` | A node is clicked (single click). | `node_id` |
| `node_data_changed` | `patch_node_data()` is called. | `node_id`, `patch` |
| `node_data_changed` | Node data is patched (via API, editor patch, or parameter-driven sync). | `node_id`, `patch` |
| `edge_added` | An edge is created (UI connect or API). | `edge` |
| `edge_deleted` | An edge is removed. | `edge_id` |
| `edge_data_changed` | `patch_edge_data()` is called. | `edge_id`, `patch` |
| `edge_data_changed` | Edge data is patched (via API, editor patch, or parameter-driven sync). | `edge_id`, `patch` |
| `selection_changed` | The active selection changes. | `nodes`, `edges` |
| `sync` | A batch sync from the frontend. | *(varies)* |

Expand Down Expand Up @@ -57,6 +57,58 @@ pn.Column(log, flow).servable()

---

## Handle events on `Node` classes

If you define nodes as `Node` subclasses, you can implement hooks directly on
the node instance:

```python
from panel_reactflow import Node, ReactFlow


class TaskNode(Node):
def on_event(self, payload, flow):
print("any node event:", payload["type"])

def on_delete(self, payload, flow):
print("deleted:", self.id)


flow = ReactFlow(nodes=[TaskNode(id="t1", position={"x": 0, "y": 0}, data={})])
```

Common hooks include `on_event` (wildcard), `on_add`, `on_move`, `on_click`,
`on_data_change`, and `on_delete`.

When a `Node` subclass parameter with `precedence >= 0` changes, it
automatically patches node data and will trigger `on_data_change`.

---

## Handle events on `Edge` classes

`Edge` subclasses can handle edge lifecycle and patch events directly:

```python
from panel_reactflow import Edge, ReactFlow


class WeightedEdge(Edge):
def on_data_change(self, payload, flow):
print("edge patch:", payload["patch"])

def on_delete(self, payload, flow):
print("edge deleted:", self.id)
```

Common edge hooks include `on_event`, `on_add`, `on_data_change`,
`on_selection_changed`, and `on_delete`.

Likewise, changing an `Edge` subclass data parameter (`precedence >= 0`)
triggers `on_data_change` through the same data patch pipeline.

---

## Listen for all events

Use the wildcard `"*"` to receive every event. This is useful for
Expand Down
138 changes: 138 additions & 0 deletions examples/node_edge_instances.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
"""Complex example using Node and Edge class instances.

Demonstrates:
- ``Node`` / ``Edge`` subclass instances in ``ReactFlow``
- Per-instance ``__panel__`` node views
- Per-instance custom editors via ``editor(...)``
- Node/edge event hooks (``on_data_change``, ``on_selection_changed``)
- Programmatic updates with ``patch_node_data`` / ``patch_edge_data``
"""

import random

import panel as pn
import panel_material_ui as pmui
import param

from panel_reactflow import Edge, Node, ReactFlow

pn.extension()


class PipelineNode(Node):
status = param.Selector(default="idle", objects=["idle", "running", "done", "failed"], precedence=0)
retries = param.Integer(default=0, bounds=(0, None), precedence=0)
owner = param.String(default="ops", precedence=0)
notes = param.String(default="", precedence=0)

def __init__(self, **params):
params.setdefault("type", "pipeline")
super().__init__(**params)
self._summary = pn.pane.Markdown(margin=(0, 0, 6, 0))
self._activity = pn.pane.Markdown("", styles={"font-size": "12px", "opacity": "0.8"})
self.param.watch(self._refresh_view, ["status", "owner", "retries", "label"])
self._refresh_view()

def _refresh_view(self, *_):
self._summary.object = (
f"**{self.label}** \n"
f"Status: `{self.status}` \n"
f"Owner: `{self.owner}` \n"
f"Retries: `{self.retries}`"
)

def __panel__(self):
return pn.Column(self._summary, self._activity, margin=0, sizing_mode="stretch_width")

def editor(self, data, schema, *, id, type, on_patch):
status = pmui.Select.from_param(self.param.status, name="Status")
retries = pmui.IntInput.from_param(self.param.retries, name="Retries")
owner = pmui.TextInput.from_param(self.param.owner, name="Owner")
notes = pmui.TextAreaInput.from_param(self.param.notes, name="Notes", height=80)
return pn.Column(status, retries, owner, notes, sizing_mode="stretch_width")

def on_data_change(self, payload, flow):
if payload.get("node_id") == self.id:
self._activity.object = f"Last patch: `{payload.get('patch', {})}`"

def on_selection_changed(self, payload, flow):
selected = self.id in (payload.get("nodes") or [])
if selected:
self._activity.object = "Selected in canvas"


class WeightedEdge(Edge):
weight = param.Number(default=0.5, bounds=(0, 1), precedence=0)
channel = param.Selector(default="main", objects=["main", "backup", "shadow"], precedence=0)
enabled = param.Boolean(default=True, precedence=0)

def __init__(self, **params):
params.setdefault("type", "weighted")
super().__init__(**params)

def editor(self, data, schema, *, id, type, on_patch):
weight = pmui.FloatSlider.from_param(self.param.weight, name="Weight", step=0.01)
channel = pmui.Select.from_param(self.param.channel, name="Channel")
enabled = pmui.Checkbox.from_param(self.param.enabled, name="Enabled")
return pn.Column(weight, channel, enabled, sizing_mode="stretch_width")


nodes = [
PipelineNode(id="extract", label="Extract", position={"x": 0, "y": 40}),
PipelineNode(id="transform", label="Transform", position={"x": 300, "y": 160}, status="running", retries=1, owner="ml", notes="Batch window"),
PipelineNode(id="load", label="Load", position={"x": 600, "y": 40}, owner="platform"),
]

edges = [
WeightedEdge(id="e1", source="extract", target="transform", weight=0.72),
WeightedEdge(id="e2", source="transform", target="load", weight=0.63, channel="backup"),
]

event_log = pmui.TextAreaInput(name="Events", value="", disabled=True, height=180, sizing_mode="stretch_width")
last_event = pn.pane.Markdown("**Last event:** _none_")

flow = ReactFlow(
nodes=nodes,
edges=edges,
editor_mode="side",
sizing_mode="stretch_both",
)

def _log_event(payload):
event_type = payload.get("type", "unknown")
last_event.object = f"**Last event:** `{event_type}`"
snippet = str(payload)
event_log.value = f"{event_log.value}\n{event_type}: {snippet}"[-6000:]


flow.on("*", _log_event)


def _advance_nodes(_):
order = {"idle": "running", "running": "done", "done": "done", "failed": "idle"}
for node in nodes:
current = node.status
flow.patch_node_data(node.id, {"status": order.get(current, "idle")})


def _randomize_weights(_):
for edge in edges:
flow.patch_edge_data(edge.id, {"weight": round(random.uniform(0.05, 0.95), 2)})


advance_btn = pmui.Button(name="Advance pipeline")
advance_btn.on_click(_advance_nodes)

weights_btn = pmui.Button(name="Randomize edge weights")
weights_btn.on_click(_randomize_weights)

controls = pn.Row(advance_btn, weights_btn, sizing_mode="stretch_width")

pn.Column(
pn.pane.Markdown("## Node/Edge Instance Workflow"),
controls,
last_event,
flow,
event_log,
sizing_mode="stretch_both",
).servable()
Loading
Loading