# Advanced Features

> Undo/redo, computing flows, sub-flows, viewport portal, accessibility, and more.

**Site index:** [https://flows.2plot.dev/llms.txt](https://flows.2plot.dev/llms.txt) — every page on this site, as Markdown.  
**Network index:** [https://2plot.dev/llms.txt](https://2plot.dev/llms.txt) — The 2plot network; start here to discover sibling sites.  
**Sibling sites:** 13 more in The 2plot network — listed in the site index above.  
**Sitemap:** https://flows.2plot.dev/sitemap.xml  


---



### Overview

The advanced feature set: history with `enableUndoRedo` / `undoRedoAction`, topological traversal with `computeAction` / `computeResult`, collapsible sub-flows (`toggleCollapseNode` / `collapsedGroups`), floating annotations via `viewportOverlays`, resize constraints, and full ARIA/keyboard accessibility.

### Live demo

Drag the nodes, pan, and zoom — the canvas below is a real, running `DashFlows` component (no callbacks, just the rendered graph):



```python
# File: docs/advanced/demo.py

"""Live, callback-free demo for the docs page. Rendered via `.. exec::docs.advanced.demo`."""
import dash_flows

nodes = [{'id': 'src', 'type': 'input', 'data': {'label': 'Input'}, 'position': {'x': 60, 'y': 40}},
 {'id': 'm1', 'type': 'default', 'data': {'label': 'Step 1'}, 'position': {'x': 60, 'y': 170}},
 {'id': 'm2', 'type': 'default', 'data': {'label': 'Step 2'}, 'position': {'x': 260, 'y': 170}},
 {'id': 'agg', 'type': 'default', 'data': {'label': 'Aggregate'}, 'position': {'x': 160, 'y': 300}},
 {'id': 'snk', 'type': 'output', 'data': {'label': 'Output'}, 'position': {'x': 160, 'y': 430}}]

edges = [{'id': 'a1', 'source': 'src', 'target': 'm1'},
 {'id': 'a2', 'source': 'src', 'target': 'm2'},
 {'id': 'a3', 'source': 'm1', 'target': 'agg', 'animated': True},
 {'id': 'a4', 'source': 'm2', 'target': 'agg'},
 {'id': 'a5', 'source': 'agg', 'target': 'snk'}]

component = dash_flows.DashFlows(
    id="advanced-demo",
    nodes=nodes,
    edges=edges,
    style={'border': '1px solid var(--mantine-color-default-border)',
 'borderRadius': '8px',
 'height': '440px'},
    showControls=True,
    showMiniMap=True,
    enableUndoRedo=True,
    helperLines=True,
    fitView=True,
    colorMode='system',
)
```

    :defaultExpanded: false
    :withExpandedButton: true

### Examples

Each example below is a complete, runnable Dash app from the [`examples/`](https://github.com/pip-install-python/dash-flows/tree/main/examples) folder. Run any of them with `python examples/<file>`.

#### Complete Showcase

A large example combining multiple node types, edge types, a group node, ELK layout switching, and live selection/count panels in one app. The full app (examples/13_complete_showcase.py) also includes a light/dark theme toggle; the embedded twin below drops that wrapper since the docs page already supplies a theme provider.



```python
# File: examples/13_complete_showcase.py

"""
Example 13: Complete Feature Showcase
=====================================
A comprehensive example combining multiple DashFlows features:
- Multiple node types with custom styling
- Various edge types
- Interactive toolbar
- Dark mode toggle
- Layout controls
- DevTools panel
"""

import dash
from dash import html, Input, Output, State, callback, clientside_callback, dcc
import dash_flows
import dash_mantine_components as dmc
import json

app = dash.Dash(__name__, suppress_callback_exceptions=True)

# Comprehensive node configuration
initial_nodes = [
    # Input nodes
    {
        "id": "input-api",
        "type": "input",
        "data": {"label": "REST API", "sublabel": "External Data"},
        "position": {"x": 50, "y": 50},
    },
    {
        "id": "input-db",
        "type": "input",
        "data": {"label": "Database", "sublabel": "PostgreSQL"},
        "position": {"x": 50, "y": 180},
    },

    # Processing nodes
    {
        "id": "process-validate",
        "type": "default",
        "data": {"label": "Validate", "sublabel": "Schema Check"},
        "position": {"x": 250, "y": 50},
    },
    {
        "id": "process-transform",
        "type": "default",
        "data": {"label": "Transform", "sublabel": "ETL Pipeline"},
        "position": {"x": 250, "y": 180},
    },
    {
        "id": "process-merge",
        "type": "toolbar",
        "data": {
            "label": "Merge Data",
            "sublabel": "Click for actions",
            "toolbarPosition": "top",
        },
        "position": {"x": 450, "y": 115},
    },

    # Group node with children
    {
        "id": "group-ml",
        "type": "group",
        "data": {"label": "ML Pipeline"},
        "position": {"x": 650, "y": 30},
        "style": {"width": 280, "height": 220},
    },
    {
        "id": "ml-train",
        "type": "default",
        "data": {"label": "Train Model"},
        "position": {"x": 30, "y": 50},
        "parentId": "group-ml",
        "extent": "parent",
    },
    {
        "id": "ml-evaluate",
        "type": "default",
        "data": {"label": "Evaluate"},
        "position": {"x": 30, "y": 130},
        "parentId": "group-ml",
        "extent": "parent",
    },

    # Output nodes
    {
        "id": "output-dashboard",
        "type": "output",
        "data": {"label": "Dashboard", "sublabel": "Visualization"},
        "position": {"x": 450, "y": 300},
    },
    {
        "id": "output-export",
        "type": "output",
        "data": {"label": "Export", "sublabel": "CSV/JSON"},
        "position": {"x": 650, "y": 300},
    },
]

initial_edges = [
    {"id": "e1", "source": "input-api", "target": "process-validate", "animated": True},
    {"id": "e2", "source": "input-db", "target": "process-transform", "type": "smoothstep"},
    {"id": "e3", "source": "process-validate", "target": "process-merge"},
    {"id": "e4", "source": "process-transform", "target": "process-merge", "type": "step"},
    {"id": "e5", "source": "process-merge", "target": "ml-train", "type": "simplebezier"},
    {"id": "e6", "source": "ml-train", "target": "ml-evaluate"},
    {"id": "e7", "source": "process-merge", "target": "output-dashboard", "type": "button", "data": {"label": "Live", "showButton": True}},
    {"id": "e8", "source": "ml-evaluate", "target": "output-export"},
]

# Layout presets
layouts = {
    "horizontal": json.dumps({
        "elk.algorithm": "layered",
        "elk.direction": "RIGHT",
        "elk.spacing.nodeNode": 60,
        "elk.layered.spacing.nodeNodeBetweenLayers": 100,
    }),
    "vertical": json.dumps({
        "elk.algorithm": "layered",
        "elk.direction": "DOWN",
        "elk.spacing.nodeNode": 60,
    }),
    "radial": json.dumps({
        "elk.algorithm": "org.eclipse.elk.radial",
        "elk.radial.radius": 180,
    }),
}

# App layout
app.layout = dmc.MantineProvider(
    id="theme-provider",
    forceColorScheme="light",
    children=[
        # Header
        dmc.Paper([
            dmc.Group([
                dmc.Title("DashFlows Complete Showcase", order=2),
                dmc.Group([
                    dmc.ActionIcon(
                        id="theme-toggle",
                        variant="subtle",
                        size="lg",
                        children=dmc.Text(id="theme-icon", size="lg"),
                    ),
                ]),
            ], justify="space-between"),
        ], p="md", mb="md", withBorder=True),

        # Controls
        dmc.Paper([
            dmc.Group([
                dmc.SegmentedControl(
                    id="layout-control",
                    data=[
                        {"value": "none", "label": "Manual"},
                        {"value": "horizontal", "label": "Horizontal"},
                        {"value": "vertical", "label": "Vertical"},
                        {"value": "radial", "label": "Radial"},
                    ],
                    value="none",
                ),
                dmc.Switch(id="show-minimap", label="MiniMap", checked=True),
                dmc.Switch(id="show-controls", label="Controls", checked=True),
                dmc.Switch(id="show-devtools", label="DevTools", checked=False),
            ], gap="lg"),
        ], p="sm", mb="md", withBorder=True),

        # Main flow area
        dmc.Grid([
            dmc.GridCol([
                html.Div(id="flow-container"),
            ], span=12 if True else 9),
        ]),

        # Info panels
        dmc.Grid([
            dmc.GridCol([
                dmc.Paper([
                    dmc.Text("Selected Items", fw=600, mb="xs"),
                    html.Div(id="selection-display"),
                ], p="md", withBorder=True),
            ], span=4),
            dmc.GridCol([
                dmc.Paper([
                    dmc.Text("Node Count", fw=600, mb="xs"),
                    html.Div(id="node-count"),
                ], p="md", withBorder=True),
            ], span=4),
            dmc.GridCol([
                dmc.Paper([
                    dmc.Text("Edge Count", fw=600, mb="xs"),
                    html.Div(id="edge-count"),
                ], p="md", withBorder=True),
            ], span=4),
        ], mt="md"),
    ],
)

# Theme toggle
clientside_callback(
    """
    function(n, scheme) {
        if (!n) return [scheme, scheme === 'dark' ? 'O' : '*'];
        const newScheme = scheme === 'light' ? 'dark' : 'light';
        return [newScheme, newScheme === 'dark' ? 'O' : '*'];
    }
    """,
    Output("theme-provider", "forceColorScheme"),
    Output("theme-icon", "children"),
    Input("theme-toggle", "n_clicks"),
    State("theme-provider", "forceColorScheme"),
    prevent_initial_call=False,
)

# Render flow with settings
@callback(
    Output("flow-container", "children"),
    Input("layout-control", "value"),
    Input("show-minimap", "checked"),
    Input("show-controls", "checked"),
    Input("show-devtools", "checked"),
)
def render_flow(layout, show_mm, show_ctrl, show_dev):
    layout_opts = layouts.get(layout) if layout != "none" else None

    return dash_flows.DashFlows(
        id="showcase-flow",
        nodes=initial_nodes,
        edges=initial_edges,
        style={"height": "500px", "borderRadius": "8px"},
        fitView=True,
        showControls=show_ctrl,
        showMiniMap=show_mm,
        showDevTools=show_dev,
        layoutOptions=layout_opts,
        backgroundVariant="dots",
        elementsSelectable=True,
        nodesConnectable=True,
        nodesDraggable=True,
    )

# Selection display
@callback(
    Output("selection-display", "children"),
    Input("showcase-flow", "selectedNodes"),
    Input("showcase-flow", "selectedEdges"),
    prevent_initial_call=True,
)
def show_selection(nodes, edges):
    # Handle various data formats
    node_ids = []
    edge_ids = []

    if nodes:
        if isinstance(nodes, list):
            for n in nodes:
                if isinstance(n, dict) and "id" in n:
                    node_ids.append(n["id"])
                elif isinstance(n, str):
                    node_ids.append(n)

    if edges:
        if isinstance(edges, list):
            for e in edges:
                if isinstance(e, dict) and "id" in e:
                    edge_ids.append(e["id"])
                elif isinstance(e, str):
                    edge_ids.append(e)

    if not node_ids and not edge_ids:
        return dmc.Text("Nothing selected", c="dimmed", size="sm")

    items = []
    if node_ids:
        items.append(dmc.Group([dmc.Badge(nid, color="blue", size="sm") for nid in node_ids], gap="xs"))
    if edge_ids:
        items.append(dmc.Group([dmc.Badge(eid, color="grape", size="sm") for eid in edge_ids], gap="xs"))

    return dmc.Stack(items, gap="xs")

# Node count
@callback(
    Output("node-count", "children"),
    Input("showcase-flow", "nodes"),
)
def show_node_count(nodes):
    count = len(nodes) if nodes else 0
    return dmc.Text(f"{count} nodes", size="xl", fw=700)

# Edge count
@callback(
    Output("edge-count", "children"),
    Input("showcase-flow", "edges"),
)
def show_edge_count(edges):
    count = len(edges) if edges else 0
    return dmc.Text(f"{count} edges", size="xl", fw=700)

if __name__ == "__main__":
    app.run(debug=True, port=8062)
```

    :defaultExpanded: false
    :withExpandedButton: true

**How it works**

- `layoutOptions` — set from a `SegmentedControl` to switch between horizontal, vertical, and radial ELK layouts on the fly
- `showMiniMap` / `showControls` / `showDevTools` — toggled by switches, demonstrating that chrome can be turned on/off per render
- `selectedNodes` / `selectedEdges` — read in a callback to render badges for the current selection
- `nodes` / `edges` (as callback Inputs) — used purely to display live counts, showing that the props stay in sync with canvas state
- `type: "group"` with `parentId` / `extent: "parent"` children — nests the ML Pipeline nodes inside a group node
- `type: "toolbar"` and `type: "button"` edge — shows the ToolbarNode and ButtonEdge working alongside default/input/output nodes

#### Callback Stress Test

A performance harness that hammers the callback chain: a 5x10 node/edge grid, batch add/remove/shuffle/connect operations (up to hundreds of elements at once), and half a dozen simultaneous listeners on click/drag/selection/connection/deletion events, all while tracking a live metrics dashboard.

This example is intentionally **not embedded** as a live demo — running it inline would fire the same high-frequency callback storm against the whole docs page and degrade every other embedded example on it. Run it standalone with `python examples/23_callback_stress_test.py` instead.


```python
# File: examples/23_callback_stress_test.py

"""
Example 23: Callback Stress Test
================================
This example stress tests Dash callbacks when interacting with DashFlows:
- Large number of nodes and edges
- Multiple simultaneous callbacks monitoring different events
- Rapid node manipulation (add, remove, update)
- Stress testing drag, selection, and connection events
- Performance metrics tracking
- Batch operations on nodes/edges
"""

import dash
from dash import html, Input, Output, State, callback, ctx, dcc, ALL, MATCH
from dash.exceptions import PreventUpdate
import dash_flows
import dash_mantine_components as dmc
import json
import time
import random
import uuid

app = dash.Dash(__name__, suppress_callback_exceptions=True)

# Configuration
GRID_ROWS = 5
GRID_COLS = 10
NODE_SPACING_X = 180
NODE_SPACING_Y = 120

def generate_grid_nodes(rows, cols):
    """Generate a grid of nodes for stress testing."""
    nodes = []
    for row in range(rows):
        for col in range(cols):
            node_id = f"node-{row}-{col}"
            node_type = "default"
            if col == 0:
                node_type = "input"
            elif col == cols - 1:
                node_type = "output"

            nodes.append({
                "id": node_id,
                "type": node_type,
                "data": {
                    "label": f"N{row},{col}",
                    "sublabel": f"idx: {row * cols + col}"
                },
                "position": {
                    "x": 50 + col * NODE_SPACING_X,
                    "y": 50 + row * NODE_SPACING_Y
                },
            })
    return nodes

def generate_grid_edges(rows, cols):
    """Generate edges connecting adjacent nodes in the grid."""
    edges = []
    for row in range(rows):
        for col in range(cols - 1):
            source = f"node-{row}-{col}"
            target = f"node-{row}-{col + 1}"
            edges.append({
                "id": f"edge-{source}-{target}",
                "source": source,
                "target": target,
                "type": random.choice(["default", "smoothstep", "step"]),
            })
    return edges

initial_nodes = generate_grid_nodes(GRID_ROWS, GRID_COLS)
initial_edges = generate_grid_edges(GRID_ROWS, GRID_COLS)

# Stress test metrics store
metrics_store = {
    "callback_counts": {},
    "last_callback_times": {},
    "total_callbacks": 0,
}

app.layout = dmc.MantineProvider([
    dcc.Store(id="metrics-store", data={
        "callbacks_fired": 0,
        "nodes_added": 0,
        "nodes_removed": 0,
        "edges_added": 0,
        "edges_removed": 0,
        "drags_completed": 0,
        "selections_made": 0,
        "connections_made": 0,
        "start_time": time.time(),
    }),
    dcc.Interval(id="metrics-interval", interval=500, n_intervals=0),

    html.H1("DashFlows Callback Stress Test", style={"marginBottom": "10px"}),

    # Control Panel
    dmc.Paper([
        dmc.Group([
            dmc.Text("Stress Test Controls:", fw=700),
            dmc.Button("Add 10 Random Nodes", id="btn-add-nodes", color="green", size="xs"),
            dmc.Button("Remove 10 Random Nodes", id="btn-remove-nodes", color="red", size="xs"),
            dmc.Button("Shuffle All Positions", id="btn-shuffle", color="blue", size="xs"),
            dmc.Button("Connect Random Pairs", id="btn-connect", color="violet", size="xs"),
            dmc.Button("Select All", id="btn-select-all", color="cyan", size="xs"),
            dmc.Button("Clear Selection", id="btn-clear-selection", color="gray", size="xs"),
            dmc.Button("Reset Grid", id="btn-reset", color="orange", size="xs"),
        ], gap="xs"),
    ], p="sm", mb="sm", withBorder=True),

    # Batch Operations Panel
    dmc.Paper([
        dmc.Group([
            dmc.Text("Batch Operations:", fw=700),
            dmc.NumberInput(id="batch-size", value=50, min=10, max=500, step=10,
                          label="Batch Size", w=120, size="xs"),
            dmc.Button("Add Batch Nodes", id="btn-batch-add", color="teal", size="xs"),
            dmc.Button("Batch Update Labels", id="btn-batch-update", color="indigo", size="xs"),
            dmc.Button("Stress Connect All", id="btn-stress-connect", color="pink", size="xs"),
        ], gap="xs"),
    ], p="sm", mb="sm", withBorder=True),

    # Main Layout
    dmc.Grid([
        # Flow Canvas
        dmc.GridCol([
            dash_flows.DashFlows(
                id="stress-flow",
                nodes=initial_nodes,
                edges=initial_edges,
                style={"height": "500px", "border": "1px solid #ddd"},
                fitView=True,
                showControls=True,
                showMiniMap=True,
                miniMapPosition="bottom-left",
                nodesDraggable=True,
                nodesConnectable=True,
                elementsSelectable=True,
                selectNodesOnDrag=False,
                backgroundVariant="dots",
                connectionMode="loose",
            ),
        ], span=9),

        # Metrics Panel
        dmc.GridCol([
            dmc.Stack([
                dmc.Paper([
                    dmc.Text("Live Metrics", fw=700, size="lg", mb="xs"),
                    html.Div(id="live-metrics"),
                ], p="sm", withBorder=True),

                dmc.Paper([
                    dmc.Text("Callback Activity", fw=700, size="lg", mb="xs"),
                    html.Div(id="callback-activity"),
                ], p="sm", withBorder=True),

                dmc.Paper([
                    dmc.Text("Current State", fw=700, size="lg", mb="xs"),
                    html.Div(id="current-state"),
                ], p="sm", withBorder=True),

                dmc.Paper([
                    dmc.Text("Last Events", fw=700, size="lg", mb="xs"),
                    html.Div(id="last-events", style={"maxHeight": "150px", "overflow": "auto"}),
                ], p="sm", withBorder=True),
            ], gap="xs"),
        ], span=3),
    ]),

    # Event Log
    dmc.Paper([
        dmc.Group([
            dmc.Text("Event Log (last 20):", fw=700),
            dmc.Button("Clear Log", id="btn-clear-log", size="xs", variant="subtle"),
        ], justify="space-between", mb="xs"),
        html.Div(id="event-log", style={
            "maxHeight": "150px",
            "overflow": "auto",
            "fontFamily": "monospace",
            "fontSize": "11px",
        }),
    ], p="sm", mt="sm", withBorder=True),

    # Hidden stores for event tracking
    dcc.Store(id="event-log-store", data=[]),
    dcc.Store(id="callback-counter", data=0),
])


# ============================================================================
# STRESS TEST CALLBACKS - Node Manipulation
# ============================================================================

@callback(
    Output("stress-flow", "nodes", allow_duplicate=True),
    Output("event-log-store", "data", allow_duplicate=True),
    Output("metrics-store", "data", allow_duplicate=True),
    Input("btn-add-nodes", "n_clicks"),
    State("stress-flow", "nodes"),
    State("event-log-store", "data"),
    State("metrics-store", "data"),
    prevent_initial_call=True,
)
def add_random_nodes(n_clicks, nodes, log, metrics):
    """Add 10 random nodes to stress test node addition."""
    if not n_clicks:
        raise PreventUpdate

    nodes = nodes or []
    new_nodes = []

    for i in range(10):
        node_id = f"rand-{uuid.uuid4().hex[:8]}"
        new_nodes.append({
            "id": node_id,
            "type": random.choice(["default", "input", "output"]),
            "data": {
                "label": f"New-{i}",
                "sublabel": f"Added #{n_clicks}"
            },
            "position": {
                "x": random.randint(50, 1500),
                "y": random.randint(50, 500)
            },
        })

    log = log or []
    log.insert(0, f"[ADD] Added 10 nodes (total: {len(nodes) + 10})")
    log = log[:20]

    metrics["nodes_added"] = metrics.get("nodes_added", 0) + 10
    metrics["callbacks_fired"] = metrics.get("callbacks_fired", 0) + 1

    return nodes + new_nodes, log, metrics


@callback(
    Output("stress-flow", "nodes", allow_duplicate=True),
    Output("stress-flow", "edges", allow_duplicate=True),
    Output("event-log-store", "data", allow_duplicate=True),
    Output("metrics-store", "data", allow_duplicate=True),
    Input("btn-remove-nodes", "n_clicks"),
    State("stress-flow", "nodes"),
    State("stress-flow", "edges"),
    State("event-log-store", "data"),
    State("metrics-store", "data"),
    prevent_initial_call=True,
)
def remove_random_nodes(n_clicks, nodes, edges, log, metrics):
    """Remove 10 random nodes to stress test node removal."""
    if not n_clicks or not nodes or len(nodes) <= 10:
        raise PreventUpdate

    nodes_to_remove = set(random.sample([n["id"] for n in nodes], min(10, len(nodes))))

    remaining_nodes = [n for n in nodes if n["id"] not in nodes_to_remove]
    remaining_edges = [e for e in (edges or [])
                       if e["source"] not in nodes_to_remove
                       and e["target"] not in nodes_to_remove]

    log = log or []
    log.insert(0, f"[REMOVE] Removed 10 nodes (remaining: {len(remaining_nodes)})")
    log = log[:20]

    metrics["nodes_removed"] = metrics.get("nodes_removed", 0) + 10
    metrics["callbacks_fired"] = metrics.get("callbacks_fired", 0) + 1

    return remaining_nodes, remaining_edges, log, metrics


@callback(
    Output("stress-flow", "nodes", allow_duplicate=True),
    Output("event-log-store", "data", allow_duplicate=True),
    Output("metrics-store", "data", allow_duplicate=True),
    Input("btn-shuffle", "n_clicks"),
    State("stress-flow", "nodes"),
    State("event-log-store", "data"),
    State("metrics-store", "data"),
    prevent_initial_call=True,
)
def shuffle_positions(n_clicks, nodes, log, metrics):
    """Shuffle all node positions to stress test batch position updates."""
    if not n_clicks or not nodes:
        raise PreventUpdate

    updated_nodes = []
    for node in nodes:
        updated_node = {**node}
        updated_node["position"] = {
            "x": random.randint(50, 1500),
            "y": random.randint(50, 500)
        }
        updated_nodes.append(updated_node)

    log = log or []
    log.insert(0, f"[SHUFFLE] Shuffled {len(nodes)} node positions")
    log = log[:20]

    metrics["callbacks_fired"] = metrics.get("callbacks_fired", 0) + 1

    return updated_nodes, log, metrics


@callback(
    Output("stress-flow", "edges", allow_duplicate=True),
    Output("event-log-store", "data", allow_duplicate=True),
    Output("metrics-store", "data", allow_duplicate=True),
    Input("btn-connect", "n_clicks"),
    State("stress-flow", "nodes"),
    State("stress-flow", "edges"),
    State("event-log-store", "data"),
    State("metrics-store", "data"),
    prevent_initial_call=True,
)
def connect_random_pairs(n_clicks, nodes, edges, log, metrics):
    """Create 10 random connections to stress test edge creation."""
    if not n_clicks or not nodes or len(nodes) < 2:
        raise PreventUpdate

    edges = edges or []
    existing_connections = {(e["source"], e["target"]) for e in edges}
    node_ids = [n["id"] for n in nodes]

    new_edges = []
    attempts = 0
    while len(new_edges) < 10 and attempts < 50:
        source = random.choice(node_ids)
        target = random.choice(node_ids)
        if source != target and (source, target) not in existing_connections:
            new_edges.append({
                "id": f"edge-{uuid.uuid4().hex[:8]}",
                "source": source,
                "target": target,
                "type": random.choice(["default", "smoothstep", "step"]),
                "animated": random.choice([True, False]),
            })
            existing_connections.add((source, target))
        attempts += 1

    log = log or []
    log.insert(0, f"[CONNECT] Created {len(new_edges)} new edges (total: {len(edges) + len(new_edges)})")
    log = log[:20]

    metrics["edges_added"] = metrics.get("edges_added", 0) + len(new_edges)
    metrics["callbacks_fired"] = metrics.get("callbacks_fired", 0) + 1

    return edges + new_edges, log, metrics


# ============================================================================
# BATCH OPERATIONS
# ============================================================================

@callback(
    Output("stress-flow", "nodes", allow_duplicate=True),
    Output("event-log-store", "data", allow_duplicate=True),
    Output("metrics-store", "data", allow_duplicate=True),
    Input("btn-batch-add", "n_clicks"),
    State("batch-size", "value"),
    State("stress-flow", "nodes"),
    State("event-log-store", "data"),
    State("metrics-store", "data"),
    prevent_initial_call=True,
)
def batch_add_nodes(n_clicks, batch_size, nodes, log, metrics):
    """Add a large batch of nodes at once."""
    if not n_clicks:
        raise PreventUpdate

    batch_size = batch_size or 50
    nodes = nodes or []
    new_nodes = []

    for i in range(batch_size):
        node_id = f"batch-{uuid.uuid4().hex[:8]}"
        new_nodes.append({
            "id": node_id,
            "type": random.choice(["default", "input", "output"]),
            "data": {
                "label": f"B{i}",
                "sublabel": f"Batch #{n_clicks}"
            },
            "position": {
                "x": random.randint(50, 2000),
                "y": random.randint(50, 800)
            },
        })

    log = log or []
    log.insert(0, f"[BATCH ADD] Added {batch_size} nodes (total: {len(nodes) + batch_size})")
    log = log[:20]

    metrics["nodes_added"] = metrics.get("nodes_added", 0) + batch_size
    metrics["callbacks_fired"] = metrics.get("callbacks_fired", 0) + 1

    return nodes + new_nodes, log, metrics


@callback(
    Output("stress-flow", "nodes", allow_duplicate=True),
    Output("event-log-store", "data", allow_duplicate=True),
    Output("metrics-store", "data", allow_duplicate=True),
    Input("btn-batch-update", "n_clicks"),
    State("stress-flow", "nodes"),
    State("event-log-store", "data"),
    State("metrics-store", "data"),
    prevent_initial_call=True,
)
def batch_update_labels(n_clicks, nodes, log, metrics):
    """Update all node labels to stress test batch updates."""
    if not n_clicks or not nodes:
        raise PreventUpdate

    updated_nodes = []
    for i, node in enumerate(nodes):
        updated_node = {**node}
        updated_node["data"] = {
            **node.get("data", {}),
            "label": f"U{i}-{n_clicks}",
            "sublabel": f"Updated #{n_clicks}"
        }
        updated_nodes.append(updated_node)

    log = log or []
    log.insert(0, f"[BATCH UPDATE] Updated {len(nodes)} node labels")
    log = log[:20]

    metrics["callbacks_fired"] = metrics.get("callbacks_fired", 0) + 1

    return updated_nodes, log, metrics


@callback(
    Output("stress-flow", "edges", allow_duplicate=True),
    Output("event-log-store", "data", allow_duplicate=True),
    Output("metrics-store", "data", allow_duplicate=True),
    Input("btn-stress-connect", "n_clicks"),
    State("stress-flow", "nodes"),
    State("stress-flow", "edges"),
    State("event-log-store", "data"),
    State("metrics-store", "data"),
    prevent_initial_call=True,
)
def stress_connect_all(n_clicks, nodes, edges, log, metrics):
    """Create many random connections to stress test edge handling."""
    if not n_clicks or not nodes or len(nodes) < 2:
        raise PreventUpdate

    edges = edges or []
    existing_connections = {(e["source"], e["target"]) for e in edges}
    node_ids = [n["id"] for n in nodes]

    # Create up to 100 new edges or 2x current nodes, whichever is smaller
    target_new_edges = min(100, len(nodes) * 2)
    new_edges = []
    attempts = 0

    while len(new_edges) < target_new_edges and attempts < target_new_edges * 5:
        source = random.choice(node_ids)
        target = random.choice(node_ids)
        if source != target and (source, target) not in existing_connections:
            new_edges.append({
                "id": f"stress-{uuid.uuid4().hex[:8]}",
                "source": source,
                "target": target,
                "type": random.choice(["default", "smoothstep", "step", "straight"]),
                "animated": random.choice([True, False, False, False]),
            })
            existing_connections.add((source, target))
        attempts += 1

    log = log or []
    log.insert(0, f"[STRESS CONNECT] Created {len(new_edges)} edges (total: {len(edges) + len(new_edges)})")
    log = log[:20]

    metrics["edges_added"] = metrics.get("edges_added", 0) + len(new_edges)
    metrics["callbacks_fired"] = metrics.get("callbacks_fired", 0) + 1

    return edges + new_edges, log, metrics


# ============================================================================
# SELECTION CALLBACKS
# ============================================================================

@callback(
    Output("stress-flow", "nodes", allow_duplicate=True),
    Output("event-log-store", "data", allow_duplicate=True),
    Output("metrics-store", "data", allow_duplicate=True),
    Input("btn-select-all", "n_clicks"),
    State("stress-flow", "nodes"),
    State("event-log-store", "data"),
    State("metrics-store", "data"),
    prevent_initial_call=True,
)
def select_all_nodes(n_clicks, nodes, log, metrics):
    """Select all nodes to stress test selection handling."""
    if not n_clicks or not nodes:
        raise PreventUpdate

    selected_nodes = [{**n, "selected": True} for n in nodes]

    log = log or []
    log.insert(0, f"[SELECT ALL] Selected {len(nodes)} nodes")
    log = log[:20]

    metrics["selections_made"] = metrics.get("selections_made", 0) + 1
    metrics["callbacks_fired"] = metrics.get("callbacks_fired", 0) + 1

    return selected_nodes, log, metrics


@callback(
    Output("stress-flow", "nodes", allow_duplicate=True),
    Output("event-log-store", "data", allow_duplicate=True),
    Output("metrics-store", "data", allow_duplicate=True),
    Input("btn-clear-selection", "n_clicks"),
    State("stress-flow", "nodes"),
    State("event-log-store", "data"),
    State("metrics-store", "data"),
    prevent_initial_call=True,
)
def clear_selection(n_clicks, nodes, log, metrics):
    """Clear selection on all nodes."""
    if not n_clicks or not nodes:
        raise PreventUpdate

    cleared_nodes = [{**n, "selected": False} for n in nodes]

    log = log or []
    log.insert(0, f"[CLEAR] Cleared selection on {len(nodes)} nodes")
    log = log[:20]

    metrics["callbacks_fired"] = metrics.get("callbacks_fired", 0) + 1

    return cleared_nodes, log, metrics


@callback(
    Output("stress-flow", "nodes", allow_duplicate=True),
    Output("stress-flow", "edges", allow_duplicate=True),
    Output("event-log-store", "data", allow_duplicate=True),
    Output("metrics-store", "data", allow_duplicate=True),
    Input("btn-reset", "n_clicks"),
    State("event-log-store", "data"),
    State("metrics-store", "data"),
    prevent_initial_call=True,
)
def reset_grid(n_clicks, log, metrics):
    """Reset to initial grid configuration."""
    if not n_clicks:
        raise PreventUpdate

    log = log or []
    log.insert(0, f"[RESET] Reset to initial {GRID_ROWS}x{GRID_COLS} grid")
    log = log[:20]

    metrics["callbacks_fired"] = metrics.get("callbacks_fired", 0) + 1

    return (
        generate_grid_nodes(GRID_ROWS, GRID_COLS),
        generate_grid_edges(GRID_ROWS, GRID_COLS),
        log,
        metrics
    )


# ============================================================================
# EVENT MONITORING CALLBACKS
# ============================================================================

@callback(
    Output("event-log-store", "data", allow_duplicate=True),
    Output("metrics-store", "data", allow_duplicate=True),
    Input("stress-flow", "clickedNode"),
    State("event-log-store", "data"),
    State("metrics-store", "data"),
    prevent_initial_call=True,
)
def on_node_click(clicked_node, log, metrics):
    """Monitor node click events."""
    if not clicked_node:
        raise PreventUpdate

    node_id = clicked_node.get("id", "unknown") if isinstance(clicked_node, dict) else clicked_node

    log = log or []
    log.insert(0, f"[CLICK] Node: {node_id}")
    log = log[:20]

    metrics["callbacks_fired"] = metrics.get("callbacks_fired", 0) + 1

    return log, metrics


@callback(
    Output("event-log-store", "data", allow_duplicate=True),
    Output("metrics-store", "data", allow_duplicate=True),
    Input("stress-flow", "draggedNode"),
    State("event-log-store", "data"),
    State("metrics-store", "data"),
    prevent_initial_call=True,
)
def on_node_drag(dragged_node, log, metrics):
    """Monitor node drag events."""
    if not dragged_node:
        raise PreventUpdate

    node_id = dragged_node.get("id", "unknown")
    is_dragging = dragged_node.get("isDragging", False)

    if not is_dragging:  # Only log when drag completes
        log = log or []
        end_pos = dragged_node.get("endPosition", {})
        log.insert(0, f"[DRAG] Node {node_id} to ({end_pos.get('x', 0):.0f}, {end_pos.get('y', 0):.0f})")
        log = log[:20]

        metrics["drags_completed"] = metrics.get("drags_completed", 0) + 1
        metrics["callbacks_fired"] = metrics.get("callbacks_fired", 0) + 1

        return log, metrics

    raise PreventUpdate


@callback(
    Output("event-log-store", "data", allow_duplicate=True),
    Output("metrics-store", "data", allow_duplicate=True),
    Input("stress-flow", "lastConnection"),
    State("event-log-store", "data"),
    State("metrics-store", "data"),
    prevent_initial_call=True,
)
def on_connection(last_connection, log, metrics):
    """Monitor new connection events."""
    if not last_connection:
        raise PreventUpdate

    source = last_connection.get("source", "?")
    target = last_connection.get("target", "?")

    log = log or []
    log.insert(0, f"[CONNECTION] {source} -> {target}")
    log = log[:20]

    metrics["connections_made"] = metrics.get("connections_made", 0) + 1
    metrics["callbacks_fired"] = metrics.get("callbacks_fired", 0) + 1

    return log, metrics


@callback(
    Output("event-log-store", "data", allow_duplicate=True),
    Output("metrics-store", "data", allow_duplicate=True),
    Input("stress-flow", "selectedNodes"),
    State("event-log-store", "data"),
    State("metrics-store", "data"),
    prevent_initial_call=True,
)
def on_selection_change(selected_nodes, log, metrics):
    """Monitor selection changes."""
    count = len(selected_nodes) if selected_nodes else 0

    log = log or []
    log.insert(0, f"[SELECTION] {count} node(s) selected")
    log = log[:20]

    metrics["selections_made"] = metrics.get("selections_made", 0) + 1
    metrics["callbacks_fired"] = metrics.get("callbacks_fired", 0) + 1

    return log, metrics


@callback(
    Output("event-log-store", "data", allow_duplicate=True),
    Output("metrics-store", "data", allow_duplicate=True),
    Input("stress-flow", "deletedNodes"),
    Input("stress-flow", "deletedEdges"),
    State("event-log-store", "data"),
    State("metrics-store", "data"),
    prevent_initial_call=True,
)
def on_delete(deleted_nodes, deleted_edges, log, metrics):
    """Monitor deletion events."""
    log = log or []

    if deleted_nodes:
        count = len(deleted_nodes)
        log.insert(0, f"[DELETE] {count} node(s) deleted")
        metrics["nodes_removed"] = metrics.get("nodes_removed", 0) + count

    if deleted_edges:
        count = len(deleted_edges)
        log.insert(0, f"[DELETE] {count} edge(s) deleted")
        metrics["edges_removed"] = metrics.get("edges_removed", 0) + count

    if not deleted_nodes and not deleted_edges:
        raise PreventUpdate

    log = log[:20]
    metrics["callbacks_fired"] = metrics.get("callbacks_fired", 0) + 1

    return log, metrics


# ============================================================================
# DISPLAY CALLBACKS
# ============================================================================

@callback(
    Output("live-metrics", "children"),
    Input("metrics-interval", "n_intervals"),
    State("metrics-store", "data"),
)
def update_live_metrics(n_intervals, metrics):
    """Update live metrics display."""
    elapsed = time.time() - metrics.get("start_time", time.time())
    callbacks_fired = metrics.get("callbacks_fired", 0)
    rate = callbacks_fired / elapsed if elapsed > 0 else 0

    return dmc.Stack([
        dmc.Group([
            dmc.Text("Elapsed:", size="sm"),
            dmc.Text(f"{elapsed:.1f}s", fw=600, size="sm"),
        ], justify="space-between"),
        dmc.Group([
            dmc.Text("Callbacks:", size="sm"),
            dmc.Text(f"{callbacks_fired}", fw=600, size="sm"),
        ], justify="space-between"),
        dmc.Group([
            dmc.Text("Rate:", size="sm"),
            dmc.Text(f"{rate:.2f}/s", fw=600, size="sm"),
        ], justify="space-between"),
    ], gap=4)


@callback(
    Output("callback-activity", "children"),
    Input("metrics-store", "data"),
)
def update_callback_activity(metrics):
    """Update callback activity display."""
    return dmc.Stack([
        dmc.Group([
            dmc.Text("Nodes Added:", size="sm"),
            dmc.Badge(str(metrics.get("nodes_added", 0)), color="green", size="sm"),
        ], justify="space-between"),
        dmc.Group([
            dmc.Text("Nodes Removed:", size="sm"),
            dmc.Badge(str(metrics.get("nodes_removed", 0)), color="red", size="sm"),
        ], justify="space-between"),
        dmc.Group([
            dmc.Text("Edges Added:", size="sm"),
            dmc.Badge(str(metrics.get("edges_added", 0)), color="blue", size="sm"),
        ], justify="space-between"),
        dmc.Group([
            dmc.Text("Edges Removed:", size="sm"),
            dmc.Badge(str(metrics.get("edges_removed", 0)), color="orange", size="sm"),
        ], justify="space-between"),
        dmc.Group([
            dmc.Text("Drags:", size="sm"),
            dmc.Badge(str(metrics.get("drags_completed", 0)), color="violet", size="sm"),
        ], justify="space-between"),
        dmc.Group([
            dmc.Text("Connections:", size="sm"),
            dmc.Badge(str(metrics.get("connections_made", 0)), color="cyan", size="sm"),
        ], justify="space-between"),
    ], gap=4)


@callback(
    Output("current-state", "children"),
    Input("stress-flow", "nodes"),
    Input("stress-flow", "edges"),
)
def update_current_state(nodes, edges):
    """Display current node and edge counts."""
    node_count = len(nodes) if nodes else 0
    edge_count = len(edges) if edges else 0

    return dmc.Stack([
        dmc.Group([
            dmc.Text("Total Nodes:", size="sm"),
            dmc.Text(f"{node_count}", fw=700, size="lg", c="blue"),
        ], justify="space-between"),
        dmc.Group([
            dmc.Text("Total Edges:", size="sm"),
            dmc.Text(f"{edge_count}", fw=700, size="lg", c="grape"),
        ], justify="space-between"),
    ], gap=4)


@callback(
    Output("last-events", "children"),
    Input("stress-flow", "clickedNode"),
    Input("stress-flow", "hoveredNode"),
    Input("stress-flow", "draggedNode"),
)
def update_last_events(clicked, hovered, dragged):
    """Display last event information."""
    events = []

    if clicked:
        node_id = clicked.get("id", clicked) if isinstance(clicked, dict) else clicked
        events.append(dmc.Text(f"Clicked: {node_id}", size="xs"))

    if hovered:
        node_id = hovered.get("id", hovered) if isinstance(hovered, dict) else hovered
        events.append(dmc.Text(f"Hovered: {node_id}", size="xs", c="dimmed"))

    if dragged:
        node_id = dragged.get("id", "?")
        is_dragging = dragged.get("isDragging", False)
        status = "dragging..." if is_dragging else "dropped"
        events.append(dmc.Text(f"Drag: {node_id} ({status})", size="xs"))

    if not events:
        events.append(dmc.Text("No recent events", size="xs", c="dimmed"))

    return dmc.Stack(events, gap=2)


@callback(
    Output("event-log", "children"),
    Input("event-log-store", "data"),
)
def update_event_log(log):
    """Display the event log."""
    if not log:
        return dmc.Text("No events yet...", c="dimmed", size="sm")

    return html.Div([
        html.Div(entry, style={"padding": "2px 0"}) for entry in log
    ])


@callback(
    Output("event-log-store", "data", allow_duplicate=True),
    Input("btn-clear-log", "n_clicks"),
    prevent_initial_call=True,
)
def clear_log(n_clicks):
    """Clear the event log."""
    return []


if __name__ == "__main__":
    app.run(debug=True, port=8093)
```

    :defaultExpanded: false
    :withExpandedButton: true

**How it works**

- `generate_grid_nodes` / `generate_grid_edges` — build a 50-node grid up front so the stress test starts with a non-trivial graph
- Batch buttons (`Add Batch Nodes`, `Stress Connect All`, etc.) — each dispatches one callback that mutates `nodes`/`edges` with `allow_duplicate=True` outputs so multiple buttons can target the same props
- `clickedNode`, `draggedNode`, `lastConnection`, `selectedNodes`, `deletedNodes` / `deletedEdges` — five independent listener callbacks log every interaction type into a shared event log store
- `dcc.Interval` — polls a metrics store every 500ms to compute a live callbacks-per-second rate, which is what the "stress test" is actually measuring

#### Phase 1 Features

A showcase of the React Flow 12.10.1 upgrade props: a drag threshold before a connection starts, z-index elevation for selected elements, auto-panning when tabbing between nodes, and an edge toolbar that appears on selection.



```python
# File: examples/25_phase1_features.py

"""
Example 25: Phase 1 Feature Showcase
=====================================
Demonstrates new features from React Flow 12.10.1 upgrade:
- connectionDragThreshold: drag distance before connection starts
- zIndexMode: selected elements elevate above others
- autoPanOnNodeFocus: viewport pans when tabbing to nodes
- Glass connection line: styled preview during edge creation
- Type-colored MiniMap: node types shown as distinct colors
- EdgeToolbar on ButtonEdge: toolbar appears on selected edges
"""

import dash
from dash import html, dcc, callback, Input, Output, State
import dash_flows

app = dash.Dash(__name__)

nodes = [
    # Input nodes (green in minimap)
    {
        "id": "source-1",
        "type": "input",
        "data": {"label": "API Source", "sublabel": "REST Endpoint"},
        "position": {"x": 50, "y": 50},
    },
    {
        "id": "source-2",
        "type": "input",
        "data": {"label": "DB Source", "sublabel": "PostgreSQL"},
        "position": {"x": 50, "y": 200},
    },

    # Default nodes (blue in minimap)
    {
        "id": "validate",
        "type": "default",
        "data": {"label": "Validate", "sublabel": "Schema Check"},
        "position": {"x": 300, "y": 50},
    },
    {
        "id": "transform",
        "type": "default",
        "data": {"label": "Transform", "sublabel": "Map & Filter"},
        "position": {"x": 300, "y": 200},
    },
    {
        "id": "merge",
        "type": "default",
        "data": {"label": "Merge", "sublabel": "Join Data"},
        "position": {"x": 550, "y": 125},
    },

    # Output nodes (purple in minimap)
    {
        "id": "output-1",
        "type": "output",
        "data": {"label": "Dashboard", "sublabel": "Visualization"},
        "position": {"x": 800, "y": 50},
    },
    {
        "id": "output-2",
        "type": "output",
        "data": {"label": "Export", "sublabel": "CSV File"},
        "position": {"x": 800, "y": 200},
    },

    # Toolbar node (amber in minimap)
    {
        "id": "config",
        "type": "toolbar",
        "data": {"label": "Config", "sublabel": "Settings"},
        "position": {"x": 550, "y": 300},
    },
]

edges = [
    # Standard edges
    {"id": "e1", "source": "source-1", "target": "validate", "type": "smoothstep", "animated": True},
    {"id": "e2", "source": "source-2", "target": "transform", "type": "smoothstep", "animated": True},
    {"id": "e3", "source": "validate", "target": "merge", "type": "smoothstep"},
    {"id": "e4", "source": "transform", "target": "merge", "type": "smoothstep"},

    # ButtonEdge with toolbar — select this edge to see the EdgeToolbar
    {
        "id": "e5",
        "source": "merge",
        "target": "output-1",
        "type": "button",
        "data": {
            "label": "Primary",
            "showButton": False,   # Hide inline delete button
            "showToolbar": True,   # EdgeToolbar appears when selected instead
        },
    },
    {
        "id": "e6",
        "source": "merge",
        "target": "output-2",
        "type": "button",
        "data": {
            "label": "Secondary",
            "showButton": False,
            "showToolbar": True,
        },
    },

    # Edge to config
    {"id": "e7", "source": "merge", "target": "config", "type": "smoothstep", "style": {"strokeDasharray": "5 5"}},
]


app.layout = html.Div([
    html.H1("Phase 1 Feature Showcase"),

    html.Div([
        html.H3("New Features Demonstrated"),
        html.Ul([
            html.Li([
                html.Strong("connectionDragThreshold=10"),
                " — Drag 10px from a handle before the connection line appears (prevents accidental connections)",
            ]),
            html.Li([
                html.Strong("zIndexMode='elevate'"),
                " — Selected nodes and their connected edges elevate above all other elements",
            ]),
            html.Li([
                html.Strong("autoPanOnNodeFocus=True"),
                " — Click inside the flow canvas first, then press Tab to cycle through nodes; viewport auto-pans to keep the focused node visible",
            ]),
            html.Li([
                html.Strong("Glass Connection Line"),
                " — Drag from any handle to see the new blue dashed connection preview with glow effect",
            ]),
            html.Li([
                html.Strong("Type-Colored MiniMap"),
                " — Check the minimap (bottom-right): green=input, blue=default, purple=output, amber=toolbar",
            ]),
            html.Li([
                html.Strong("EdgeToolbar"),
                " — Click the 'Primary' or 'Secondary' edge line (not a node) to select it — a glass toolbar with edit/delete buttons appears above the edge",
            ]),
        ]),
    ], style={"padding": "0 16px", "fontSize": "14px"}),

    dash_flows.DashFlows(
        id="feature-flow",
        nodes=nodes,
        edges=edges,
        # New Phase 1 props
        connectionDragThreshold=10,
        zIndexMode="elevate",
        autoPanOnNodeFocus=True,
        # Standard display options
        style={"height": "600px", "border": "1px solid #ddd"},
        fitView=True,
        fitViewOptions={"padding": 0.15},
        showControls=True,
        showMiniMap=True,
        showBackground=True,
        colorScheme="default",
    ),
])


if __name__ == "__main__":
    app.run(debug=True, port=8025)
```

    :defaultExpanded: false
    :withExpandedButton: true

**How it works**

- `connectionDragThreshold=10` — the connection line only appears after dragging 10px from a handle, preventing accidental connections on a light click
- `zIndexMode="elevate"` — selected nodes and their connected edges render above everything else
- `autoPanOnNodeFocus=True` — tabbing through nodes (after clicking into the canvas) pans the viewport to keep the focused node visible
- `data.showToolbar=True` on a `type="button"` edge — replaces the inline delete button with a floating `EdgeToolbar` that appears when the edge is selected
- MiniMap node coloring is automatic by node `type` (green=input, blue=default, purple=output, amber=toolbar) — no extra prop needed

#### Accessibility

Custom ARIA labels for the diagram, minimap, and controls, per-node/per-edge `ariaLabel` overrides, and keyboard-only navigation via focusable nodes and edges.



```python
# File: examples/29_accessibility.py

"""
Example 29: Accessibility / ARIA Pass-through
==============================================
Demonstrates ARIA label customization, keyboard navigation, and focus
management for screen-reader and keyboard-only users.

Key features:
- ariaLabelConfig: custom labels for the diagram, minimap, and controls
- Per-node / per-edge ariaLabel: individual element descriptions
- nodesFocusable / edgesFocusable: Tab key navigates between elements
- disableKeyboardA11y=False: arrow-key panning and keyboard shortcuts active
"""

import dash
from dash import html, dcc, callback, Input, Output
import dash_flows

app = dash.Dash(__name__)

nodes = [
    {
        "id": "start",
        "type": "input",
        "data": {"label": "Start", "sublabel": "Entry point"},
        "position": {"x": 50, "y": 120},
        "ariaLabel": "Start process node — entry point of the workflow",
    },
    {
        "id": "validate",
        "type": "default",
        "data": {"label": "Validate", "sublabel": "Check data"},
        "position": {"x": 280, "y": 50},
        "ariaLabel": "Validation node — checks input data for correctness",
    },
    {
        "id": "process",
        "type": "default",
        "data": {"label": "Process", "sublabel": "Transform"},
        "position": {"x": 280, "y": 200},
        "ariaLabel": "Processing node — transforms validated data",
    },
    {
        "id": "end",
        "type": "output",
        "data": {"label": "Complete", "sublabel": "Output"},
        "position": {"x": 510, "y": 120},
        "ariaLabel": "Completion node — final output of the workflow",
    },
]

edges = [
    {"id": "e1", "source": "start",    "target": "validate", "ariaLabel": "Flow from start to validation"},
    {"id": "e2", "source": "start",    "target": "process",  "ariaLabel": "Flow from start to processing"},
    {"id": "e3", "source": "validate", "target": "end",      "ariaLabel": "Flow from validation to completion"},
    {"id": "e4", "source": "process",  "target": "end",      "ariaLabel": "Flow from processing to completion"},
]

aria_config = {
    "rfDiagram": "Interactive workflow diagram with 4 nodes and 4 connections",
    "miniMap":   "Minimap navigation — overview of the workflow diagram",
    "controls":  "Zoom and pan controls for the workflow diagram",
}

# Keyboard shortcut reference table
shortcuts = [
    ("Tab / Shift+Tab",  "Move focus between nodes and edges"),
    ("Enter / Space",    "Select the focused element"),
    ("Escape",           "Clear selection / close any overlay"),
    ("Arrow keys",       "Pan the viewport (when focus is on the canvas)"),
    ("+ / −",            "Zoom in / zoom out"),
    ("Delete / Backspace","Delete selected node or edge"),
]

app.layout = html.Div([
    html.H2("Accessibility / ARIA Labels"),
    html.P(
        "Tab through nodes and edges using the keyboard. "
        "Each element has an ARIA label that screen readers will announce. "
        "Inspect the DOM to see all applied aria-* attributes.",
        style={"color": "#666", "marginBottom": "12px"},
    ),

    # Keyboard shortcut reference
    html.Details([
        html.Summary("⌨  Keyboard shortcuts (click to expand)",
                     style={"cursor": "pointer", "fontWeight": "500", "fontSize": "13px", "color": "#374151"}),
        html.Table([
            html.Tbody([
                html.Tr([
                    html.Td(k, style={"padding": "3px 10px 3px 0", "fontFamily": "monospace",
                                      "fontSize": "12px", "whiteSpace": "nowrap", "color": "#1d4ed8"}),
                    html.Td(v, style={"padding": "3px 0", "fontSize": "12px", "color": "#374151"}),
                ])
                for k, v in shortcuts
            ]),
        ], style={"borderCollapse": "collapse", "marginTop": "6px"}),
    ], style={
        "background": "#f0f7ff", "border": "1px solid #bfdbfe",
        "borderRadius": "8px", "padding": "10px 14px", "marginBottom": "12px",
    }),

    dash_flows.DashFlows(
        id="accessible-flow",
        nodes=nodes,
        edges=edges,
        ariaLabelConfig=aria_config,
        fitView=True,
        showControls=True,
        showMiniMap=True,
        showBackground=True,
        disableKeyboardA11y=False,
        nodesFocusable=True,
        edgesFocusable=True,
        style={"height": "420px"},
    ),

    # Live info panel
    html.Div(id="a11y-info", style={
        "marginTop": "12px", "padding": "12px 14px",
        "background": "#f9fafb", "border": "1px solid #e5e7eb",
        "borderRadius": "8px", "minHeight": "56px",
    }),
], style={"padding": "20px"})


@callback(
    Output("a11y-info", "children"),
    Input("accessible-flow", "clickedNode"),
    Input("accessible-flow", "clickedEdge"),
    prevent_initial_call=True,
)
def show_element_info(clicked_node, clicked_edge):
    from dash import ctx
    if ctx.triggered_id == "accessible-flow" and clicked_node:
        nid = clicked_node["id"]
        label = clicked_node["data"].get("label", nid)
        # Find the matching ariaLabel
        aria = next((n["ariaLabel"] for n in nodes if n["id"] == nid), "No ARIA label set")
        return html.Div([
            html.Strong(f"Node clicked: {label}"),
            html.Br(),
            html.Span(f"ARIA label: \"{aria}\"",
                      style={"fontSize": "12px", "color": "#6b7280", "fontStyle": "italic"}),
            html.Br(),
            html.Span("Tip: Tab to next node, Delete/Backspace to remove selection",
                      style={"fontSize": "11px", "color": "#9ca3af"}),
        ])
    if ctx.triggered_id == "accessible-flow" and clicked_edge:
        eid = clicked_edge["id"]
        aria = next((e["ariaLabel"] for e in edges if e["id"] == eid), "No ARIA label set")
        return html.Div([
            html.Strong(f"Edge clicked: {eid}"),
            html.Br(),
            html.Span(f"ARIA label: \"{aria}\"",
                      style={"fontSize": "12px", "color": "#6b7280", "fontStyle": "italic"}),
        ])
    return html.Span("Click a node or edge to see its ARIA label.",
                     style={"color": "#9ca3af", "fontSize": "13px"})


if __name__ == "__main__":
    app.run(debug=True, port=8029)
```

    :defaultExpanded: false
    :withExpandedButton: true

**How it works**

- `ariaLabelConfig` — supplies custom labels for the `rfDiagram`, `miniMap`, and `controls` regions, read by screen readers
- `ariaLabel` on individual node/edge dicts — gives each element its own accessible description, shown here in the info panel below the canvas
- `nodesFocusable` / `edgesFocusable` — makes every node and edge reachable via Tab / Shift+Tab
- `disableKeyboardA11y=False` — keeps arrow-key panning, Enter/Space selection, and Delete/Backspace shortcuts active
- `clickedNode` / `clickedEdge` — read in a callback to display which element was activated and its ARIA label

#### Resize Constraints

Min/max width and height bounds, plus locked aspect ratios, applied to both `ResizableNode` and `GroupNode`.



```python
# File: examples/30_resize_constraints.py

"""
Example 30: Node Resize Constraints
====================================
Demonstrates resize constraints on ResizableNode and GroupNode:
- keepAspectRatio: Lock aspect ratio during resize
- maxWidth / maxHeight: Maximum size limits
"""

import dash
from dash import html
import dash_flows

app = dash.Dash(__name__)

nodes = [
    # Resizable node with aspect ratio lock
    {
        "id": "aspect-locked",
        "type": "resizable",
        "data": {
            "label": "Aspect Ratio Locked (2:1)",
            "handles": [
                {"id": "source-right", "type": "source", "position": "right"},
                {"id": "target-left", "type": "target", "position": "left"},
            ],
            "keepAspectRatio": True,
            "initialWidth": 300,
            "initialHeight": 150,
            "minWidth": 200,
            "minHeight": 100,
        },
        "position": {"x": 50, "y": 50},
        "style": {"width": 300, "height": 150},
    },
    # Resizable node with max dimensions
    {
        "id": "max-constrained",
        "type": "resizable",
        "data": {
            "label": "Max 400x200",
            "handles": [
                {"id": "source-right", "type": "source", "position": "right"},
                {"id": "target-left", "type": "target", "position": "left"},
            ],
            "maxWidth": 400,
            "maxHeight": 200,
            "minWidth": 150,
            "minHeight": 80,
        },
        "position": {"x": 50, "y": 280},
        "style": {"width": 250, "height": 120},
    },
    # Group node with resize constraints
    {
        "id": "constrained-group",
        "type": "group",
        "data": {
            "label": "Constrained Group (max 500x400)",
            "maxWidth": 500,
            "maxHeight": 400,
            "minWidth": 200,
            "minHeight": 150,
        },
        "position": {"x": 450, "y": 50},
        "style": {"width": 350, "height": 300},
    },
    # Child inside group
    {
        "id": "child-1",
        "type": "default",
        "data": {"label": "Child Node A"},
        "position": {"x": 30, "y": 50},
        "parentId": "constrained-group",
        "extent": "parent",
    },
    {
        "id": "child-2",
        "type": "default",
        "data": {"label": "Child Node B"},
        "position": {"x": 30, "y": 150},
        "parentId": "constrained-group",
        "extent": "parent",
    },
    # Group with aspect ratio lock
    {
        "id": "aspect-group",
        "type": "group",
        "data": {
            "label": "Aspect Locked Group",
            "keepAspectRatio": True,
            "minWidth": 150,
            "minHeight": 150,
        },
        "position": {"x": 450, "y": 380},
        "style": {"width": 250, "height": 250},
    },
]

edges = [
    {"id": "e1", "source": "aspect-locked", "target": "max-constrained"},
    {"id": "e2", "source": "child-1", "target": "child-2"},
]

app.layout = html.Div([
    html.H2("Node Resize Constraints"),

    # Constraint summary
    html.Div([
        html.Strong("How to resize: ", style={"fontSize": "13px"}),
        html.Span("Click a node to select it, then drag the blue corner / edge handles that appear.",
                  style={"fontSize": "13px", "color": "#555"}),
        html.Br(),
        html.Div([
            html.Span("■ Top-left ResizableNode",  style={"color": "#3b82f6", "fontWeight": "600", "fontSize": "12px"}),
            html.Span(" — aspect ratio locked 2:1, min 200×100",           style={"fontSize": "12px", "color": "#555", "marginRight": "18px"}),
            html.Span("■ Bottom-left ResizableNode", style={"color": "#8b5cf6", "fontWeight": "600", "fontSize": "12px"}),
            html.Span(" — max 400×200, min 150×80",                         style={"fontSize": "12px", "color": "#555"}),
        ], style={"marginTop": "4px"}),
        html.Div([
            html.Span("■ Right GroupNode",           style={"color": "#10b981", "fontWeight": "600", "fontSize": "12px"}),
            html.Span(" — max 500×400, min 200×150",                         style={"fontSize": "12px", "color": "#555", "marginRight": "18px"}),
            html.Span("■ Bottom-right GroupNode",    style={"color": "#f59e0b", "fontWeight": "600", "fontSize": "12px"}),
            html.Span(" — aspect ratio locked, min 150×150",                 style={"fontSize": "12px", "color": "#555"}),
        ], style={"marginTop": "2px"}),
    ], style={
        "background": "#f9fafb", "border": "1px solid #e5e7eb",
        "borderRadius": "8px", "padding": "10px 14px", "marginBottom": "12px",
    }),

    dash_flows.DashFlows(
        id="resize-flow",
        nodes=nodes,
        edges=edges,
        fitView=True,
        showControls=True,
        showMiniMap=True,
        showBackground=True,
        style={"height": "700px"},
    ),
], style={"padding": "20px"})

if __name__ == "__main__":
    app.run(debug=True, port=8030)
```

    :defaultExpanded: false
    :withExpandedButton: true

**How it works**

- `data.minWidth` / `data.maxWidth` / `data.minHeight` / `data.maxHeight` — clamp how far a `resizable` node or `group` node can be dragged in each dimension
- `data.keepAspectRatio=True` — locks the width:height ratio while resizing, regardless of which handle is dragged
- `data.handles` — customizes which side(s) of a `resizable` node expose source/target connection points
- Group resize constraints apply to the container only — child nodes with `parentId` / `extent: "parent"` stay clipped to whatever size the group currently is

#### Undo / Redo

History tracking for node moves, connections, and deletions, with dedicated Undo/Redo buttons and a live history counter.



```python
# File: examples/32_undo_redo.py

"""
Example 32: Undo/Redo System
=============================
Demonstrates the undo/redo history system. Move nodes, create connections,
delete elements — then undo and redo your changes.

Features demonstrated:
- Node movement undo/redo (drag a node, then undo)
- Node/edge deletion undo/redo (select + Delete key, or use button)
- Connection creation undo (draw an edge between nodes)
- History counter shows pending undo/redo steps

Uses experimental_useOnNodesChangeMiddleware and
experimental_useOnEdgesChangeMiddleware from React Flow 12.10.1.
"""

import dash
from dash import html, dcc, callback, Input, Output, State
import dash_flows

app = dash.Dash(__name__)

initial_nodes = [
    {"id": "a", "type": "input",   "data": {"label": "Node A", "sublabel": "Source"},   "position": {"x": 50,  "y": 150}},
    {"id": "b", "type": "default", "data": {"label": "Node B", "sublabel": "Process 1"}, "position": {"x": 280, "y": 50}},
    {"id": "c", "type": "default", "data": {"label": "Node C", "sublabel": "Process 2"}, "position": {"x": 280, "y": 250}},
    {"id": "d", "type": "output",  "data": {"label": "Node D", "sublabel": "Output"},   "position": {"x": 510, "y": 150}},
]

initial_edges = [
    {"id": "e-ab", "source": "a", "target": "b"},
    {"id": "e-ac", "source": "a", "target": "c"},
    {"id": "e-bd", "source": "b", "target": "d"},
]

btn_style = {
    "padding": "8px 16px",
    "border": "1px solid #ddd",
    "borderRadius": "6px",
    "cursor": "pointer",
    "fontSize": "13px",
    "fontWeight": "500",
    "minWidth": "80px",
}

app.layout = html.Div([
    html.H2("Undo/Redo System"),

    # Instruction panel
    html.Div([
        html.Div([
            html.Strong("Try these actions:"),
            html.Ul([
                html.Li("Drag a node to a new position → then Undo"),
                html.Li("Select a node (click) → press Delete or Backspace → then Undo"),
                html.Li("Draw a new edge by dragging from a handle → then Undo"),
                html.Li("Use the Delete Selected button below to remove selected nodes"),
            ], style={"margin": "6px 0 0 0", "paddingLeft": "18px", "fontSize": "13px", "color": "#555"}),
        ]),
    ], style={
        "background": "#f0f7ff",
        "border": "1px solid #bfdbfe",
        "borderRadius": "8px",
        "padding": "10px 14px",
        "marginBottom": "10px",
        "fontSize": "13px",
    }),

    # Controls row
    html.Div([
        html.Button(
            "⟲ Undo", id="btn-undo",
            style={**btn_style, "background": "#fee2e2", "borderColor": "#fca5a5"},
            disabled=True,
        ),
        html.Button(
            "⟳ Redo", id="btn-redo",
            style={**btn_style, "background": "#dbeafe", "borderColor": "#93c5fd"},
            disabled=True,
        ),
        html.Button(
            "🗑 Delete Selected", id="btn-delete",
            style={**btn_style, "background": "#fef9c3", "borderColor": "#fde047"},
        ),
        html.Span(id="history-info", style={
            "padding": "8px 14px",
            "background": "#f0f0f0",
            "borderRadius": "6px",
            "fontSize": "12px",
            "color": "#555",
            "marginLeft": "4px",
        }),
    ], style={"display": "flex", "gap": "8px", "alignItems": "center", "marginBottom": "10px"}),

    # Store to hold selected node IDs for delete action
    dcc.Store(id="selected-ids-store"),

    dash_flows.DashFlows(
        id="undo-redo-flow",
        nodes=initial_nodes,
        edges=initial_edges,
        fitView=True,
        enableUndoRedo=True,
        undoRedoMaxHistory=50,
        showControls=True,
        showMiniMap=True,
        showBackground=True,
        style={"height": "500px"},
    ),

    html.Div(id="action-log", style={
        "marginTop": "10px",
        "padding": "8px 12px",
        "background": "#fafafa",
        "border": "1px solid #e5e7eb",
        "borderRadius": "6px",
        "fontSize": "12px",
        "color": "#6b7280",
        "minHeight": "32px",
    }),
], style={"padding": "20px"})


@callback(
    Output("undo-redo-flow", "undoRedoAction"),
    Input("btn-undo", "n_clicks"),
    Input("btn-redo", "n_clicks"),
    prevent_initial_call=True,
)
def handle_undo_redo(undo_clicks, redo_clicks):
    from dash import ctx
    if ctx.triggered_id == "btn-undo":
        return {"action": "undo"}
    elif ctx.triggered_id == "btn-redo":
        return {"action": "redo"}
    return dash.no_update


@callback(
    Output("btn-undo", "disabled"),
    Output("btn-redo", "disabled"),
    Output("history-info", "children"),
    Input("undo-redo-flow", "undoRedoState"),
)
def update_buttons(state):
    if not state:
        return True, True, "History: 0 undo / 0 redo"
    undo_count = state.get("undoCount", 0)
    redo_count = state.get("redoCount", 0)
    label = f"History: {undo_count} undo / {redo_count} redo"
    return (
        not state.get("canUndo", False),
        not state.get("canRedo", False),
        label,
    )


@callback(
    Output("selected-ids-store", "data"),
    Input("undo-redo-flow", "selectedNodes"),
    Input("undo-redo-flow", "selectedEdges"),
)
def track_selected(sel_nodes, sel_edges):
    # selectedNodes / selectedEdges are already arrays of string IDs
    return {"nodes": sel_nodes or [], "edges": sel_edges or []}


@callback(
    Output("undo-redo-flow", "deleteElementsAction"),
    Output("action-log", "children"),
    Input("btn-delete", "n_clicks"),
    State("selected-ids-store", "data"),
    prevent_initial_call=True,
)
def delete_selected(n_clicks, selected):
    if not selected:
        return dash.no_update, "Nothing selected to delete."

    node_ids = selected.get("nodes", [])
    edge_ids = selected.get("edges", [])

    if not node_ids and not edge_ids:
        return dash.no_update, "Nothing selected to delete."

    parts = []
    if node_ids:
        parts.append(f"{len(node_ids)} node(s)")
    if edge_ids:
        parts.append(f"{len(edge_ids)} edge(s)")
    msg = f"Deleted {' and '.join(parts)}. Press ⟲ Undo to restore."

    # Route through deleteElementsAction so React Flow's normal deletion
    # flow fires — the undo/redo middleware can then capture the snapshot.
    return {"nodeIds": node_ids, "edgeIds": edge_ids}, msg


if __name__ == "__main__":
    app.run(debug=True, port=8032)
```

    :defaultExpanded: false
    :withExpandedButton: true

**How it works**

- `enableUndoRedo=True` and `undoRedoMaxHistory=50` — turns on history tracking and caps how many snapshots are kept
- `undoRedoAction` — set to `{"action": "undo"}` or `{"action": "redo"}` from the button callbacks to step through history
- `undoRedoState` — an output prop with `canUndo` / `canRedo` / `undoCount` / `redoCount`, used here to enable/disable the buttons and render the history badge
- `deleteElementsAction` — routes the "Delete Selected" button through React Flow's normal deletion pipeline (rather than mutating `nodes`/`edges` directly) so the undo/redo middleware can capture the change
- `selectedNodes` / `selectedEdges` — tracked in a store so the delete button knows what to remove

#### Computing Flows

Topological sort and value propagation across a small pipeline graph: input nodes hold values, operation nodes combine them, and an output node shows the final result.



```python
# File: examples/33_computing_flows.py

"""
Example 33: Computing Flows
============================
Demonstrates graph traversal and computation propagation.
Input nodes have numbers, operation nodes perform math,
output nodes show results. Click "Compute" to propagate values.

JS does topological sort only — Python handles all business logic.

Pipeline: (A + B) × C = (10 + 5) × 3 = 45
"""

import dash
from dash import html, callback, Input, Output, State
import dash_flows

app = dash.Dash(__name__)

initial_nodes = [
    {
        "id": "input-a",
        "type": "input",
        "data": {"label": "Input A", "sublabel": "Value: 10", "computedValue": 10},
        "position": {"x": 50, "y": 50},
    },
    {
        "id": "input-b",
        "type": "input",
        "data": {"label": "Input B", "sublabel": "Value: 5", "computedValue": 5},
        "position": {"x": 50, "y": 200},
    },
    {
        "id": "input-c",
        "type": "input",
        "data": {"label": "Input C", "sublabel": "Value: 3", "computedValue": 3},
        "position": {"x": 50, "y": 350},
    },
    {
        "id": "add",
        "type": "default",
        "data": {"label": "Add (+)", "sublabel": "A + B", "operation": "add", "computedValue": None},
        "position": {"x": 300, "y": 100},
    },
    {
        "id": "multiply",
        "type": "default",
        "data": {"label": "Multiply (×)", "sublabel": "result × C", "operation": "multiply", "computedValue": None},
        "position": {"x": 550, "y": 200},
    },
    {
        "id": "output",
        "type": "output",
        "data": {"label": "Result", "sublabel": "Waiting...", "computedValue": None},
        "position": {"x": 800, "y": 200},
    },
]

initial_edges = [
    {"id": "e1", "source": "input-a", "target": "add"},
    {"id": "e2", "source": "input-b", "target": "add"},
    {"id": "e3", "source": "add", "target": "multiply"},
    {"id": "e4", "source": "input-c", "target": "multiply"},
    {"id": "e5", "source": "multiply", "target": "output"},
]

btn_style = {
    "padding": "10px 24px",
    "border": "none",
    "borderRadius": "8px",
    "background": "linear-gradient(135deg, #3b82f6, #8b5cf6)",
    "color": "white",
    "cursor": "pointer",
    "fontSize": "14px",
    "fontWeight": "600",
}

reset_btn_style = {
    "padding": "10px 18px",
    "border": "1px solid #ddd",
    "borderRadius": "8px",
    "background": "#f9fafb",
    "color": "#374151",
    "cursor": "pointer",
    "fontSize": "14px",
}

app.layout = html.Div([
    html.H2("Computing Flows"),
    html.P(
        "Pipeline: (A + B) × C → Result.  Click ⚡ Compute to propagate values through the graph.",
        style={"color": "#666"},
    ),

    html.Div([
        html.Button("⚡ Compute", id="btn-compute", style=btn_style),
        html.Button("↺ Reset", id="btn-reset", style=reset_btn_style),
        html.Span(id="compute-status", style={
            "padding": "8px 16px", "background": "#f0f0f0",
            "borderRadius": "6px", "fontSize": "13px",
        }),
    ], style={"display": "flex", "gap": "10px", "alignItems": "center", "marginBottom": "10px"}),

    dash_flows.DashFlows(
        id="compute-flow",
        nodes=initial_nodes,
        edges=initial_edges,
        fitView=True,
        showControls=True,
        showMiniMap=True,
        showBackground=True,
        smartHandles=True,
        style={"height": "500px"},
    ),
], style={"padding": "20px"})


@callback(
    Output("compute-flow", "computeAction"),
    Input("btn-compute", "n_clicks"),
    prevent_initial_call=True,
)
def trigger_compute(n):
    return {"action": "compute"}


@callback(
    Output("compute-flow", "nodes"),
    Output("compute-status", "children"),
    Input("compute-flow", "computeResult"),
    State("compute-flow", "nodes"),
    prevent_initial_call=True,
)
def process_computation(result, nodes):
    if not result:
        return dash.no_update, dash.no_update

    traversal = result.get("traversalOrder", [])
    node_inputs_meta = result.get("nodeInputs", {})

    node_map = {n["id"]: n for n in nodes}

    # Seed computed_values from nodes that already have a value (source nodes).
    # We maintain our own dict so intermediate results propagate correctly
    # through the topological order — the JS snapshot only knows values that
    # existed BEFORE this compute call.
    computed_values = {}
    for n in nodes:
        val = n["data"].get("computedValue")
        if val is not None:
            computed_values[n["id"]] = val

    steps = []

    for node_id in traversal:
        node = node_map.get(node_id)
        if not node:
            continue

        meta = node_inputs_meta.get(node_id, {})
        incoming = meta.get("inputs", [])  # [{nodeId, value, data}, ...]
        operation = node["data"].get("operation")

        if not incoming:
            # Source node — keep its seed value, nothing to compute
            continue

        # Use our locally propagated computed_values for inputs, not the
        # stale JS snapshot values (which are None for intermediate nodes).
        input_values = [
            computed_values[inp["nodeId"]]
            for inp in incoming
            if inp["nodeId"] in computed_values
        ]

        if not input_values:
            continue

        if operation == "add":
            computed = sum(input_values)
            steps.append(f"{node_id}: {' + '.join(str(v) for v in input_values)} = {computed}")
        elif operation == "multiply":
            computed = 1
            for v in input_values:
                computed *= v
            steps.append(f"{node_id}: {' × '.join(str(v) for v in input_values)} = {computed}")
        else:
            # Output node or unknown: pass through
            computed = sum(input_values)
            steps.append(f"{node_id}: result = {computed}")

        computed_values[node_id] = computed
        node["data"] = dict(node["data"])
        node["data"]["computedValue"] = computed
        node["data"]["sublabel"] = f"= {computed}"

    updated_nodes = list(node_map.values())
    final_value = computed_values.get("output", "?")
    status = f"✓ Result = {final_value}   ({' → '.join(steps)})"

    return updated_nodes, status


@callback(
    Output("compute-flow", "nodes", allow_duplicate=True),
    Output("compute-status", "children", allow_duplicate=True),
    Input("btn-reset", "n_clicks"),
    prevent_initial_call=True,
)
def reset_flow(n):
    import copy
    return copy.deepcopy(initial_nodes), "Reset — click ⚡ Compute to run again."


if __name__ == "__main__":
    app.run(debug=True, port=8033)
```

    :defaultExpanded: false
    :withExpandedButton: true

**How it works**

- `computeAction={"action": "compute"}` — triggers a client-side topological sort of the graph
- `computeResult` — an output prop with `traversalOrder` (node IDs in dependency order) and `nodeInputs` (each node's incoming values); JS only does the sort, Python does all the math
- The callback walks `traversalOrder`, applies each node's `data.operation` (`add` / `multiply`) to its inputs, and writes the running value back into `data.computedValue` / `data.sublabel`
- `smartHandles=True` — lets edges route to whichever side of a node is closest, useful once the pipeline fans out
- The "Reset" button restores `initial_nodes` via `copy.deepcopy` so a fresh compute run starts from the original seed values

#### Viewport Portal

Floating annotations rendered at specific flow coordinates that pan and zoom together with the canvas, with a small editor to add, edit, and remove them.



```python
# File: examples/34_viewport_portal.py

"""
Example 34: ViewportPortal — Floating Annotations
===================================================
Demonstrates floating annotations rendered at specific flow coordinates
via ViewportPortal. Overlays move with pan/zoom.

Features:
- Add / remove annotations
- Select an annotation from the list and edit its x, y position and content
- Changes update immediately in the flow
"""

import dash
from dash import html, dcc, callback, Input, Output, State
import dash_flows

app = dash.Dash(__name__)

nodes = [
    {"id": "server", "type": "default", "data": {"label": "API Server",  "sublabel": "Port 8080"},  "position": {"x": 100, "y": 100}},
    {"id": "db",     "type": "default", "data": {"label": "Database",    "sublabel": "PostgreSQL"}, "position": {"x": 400, "y": 100}},
    {"id": "cache",  "type": "default", "data": {"label": "Cache",       "sublabel": "Redis"},      "position": {"x": 400, "y": 280}},
    {"id": "client", "type": "input",   "data": {"label": "Client App"},                            "position": {"x": -150, "y": 100}},
    {"id": "cdn",    "type": "output",  "data": {"label": "CDN"},                                   "position": {"x": 700, "y": 100}},
]

edges = [
    {"id": "e1", "source": "client", "target": "server"},
    {"id": "e2", "source": "server", "target": "db"},
    {"id": "e3", "source": "server", "target": "cache"},
    {"id": "e4", "source": "db",     "target": "cdn"},
]

initial_overlays = [
    {
        "x": -150, "y": 58,
        "content": "🌐 External Traffic",
        "style": {
            "background": "rgba(59,130,246,0.1)", "border": "1px dashed rgba(59,130,246,0.4)",
            "padding": "4px 10px", "borderRadius": "6px",
            "fontSize": "11px", "color": "#3b82f6", "fontWeight": "600", "whiteSpace": "nowrap",
        },
    },
    {
        "x": 200, "y": 68,
        "content": "← REST API →",
        "style": {
            "fontSize": "10px", "color": "#888", "fontStyle": "italic", "whiteSpace": "nowrap",
        },
    },
    {
        "x": 380, "y": 200,
        "content": "⚡ Hot path",
        "style": {
            "background": "rgba(245,158,11,0.15)", "border": "1px solid rgba(245,158,11,0.3)",
            "padding": "3px 8px", "borderRadius": "4px",
            "fontSize": "10px", "color": "#d97706", "fontWeight": "500", "whiteSpace": "nowrap",
        },
    },
    {
        "x": 100, "y": -2,
        "content": "── Internal Network ──────────────────────",
        "style": {
            "fontSize": "10px", "color": "#aaa", "letterSpacing": "1px", "whiteSpace": "nowrap",
        },
    },
]

def _build_options(overlays):
    return [
        {"label": f"[{i}] {o['content'][:28]}{'…' if len(o['content']) > 28 else ''}",
         "value": i}
        for i, o in enumerate(overlays)
    ]


# Shared button style
_btn = {
    "padding": "7px 14px", "border": "1px solid #ddd",
    "borderRadius": "6px", "background": "#fff", "cursor": "pointer", "fontSize": "13px",
}

app.layout = html.Div([
    html.H2("ViewportPortal — Floating Annotations"),
    html.P(
        "Annotations are anchored to flow coordinates and move with pan/zoom. "
        "Select one from the list below to edit its position or content.",
        style={"color": "#666"},
    ),

    # Top controls
    html.Div([
        html.Button("＋ Add Annotation", id="btn-add-overlay",   style=_btn),
        html.Button("✕ Clear All",        id="btn-clear-overlays", style=_btn),
    ], style={"display": "flex", "gap": "8px", "marginBottom": "10px"}),

    # Flow
    dash_flows.DashFlows(
        id="portal-flow",
        nodes=nodes,
        edges=edges,
        viewportOverlays=initial_overlays,
        fitView=True,
        showControls=True,
        showMiniMap=True,
        showBackground=True,
        smartHandles=True,
        style={"height": "460px"},
    ),

    # Editor panel
    html.Div([
        html.H4("Edit Annotations", style={"margin": "0 0 10px 0", "fontSize": "14px"}),
        html.Div(style={"display": "flex", "gap": "16px", "alignItems": "flex-start"}, children=[

            # Annotation list (left side)
            html.Div([
                html.Div("Select:", style={"fontSize": "12px", "color": "#888", "marginBottom": "4px"}),
                dcc.RadioItems(
                    id="annotation-selector",
                    options=_build_options(initial_overlays),
                    value=None,
                    labelStyle={"display": "block", "fontSize": "13px", "padding": "3px 0", "cursor": "pointer"},
                ),
            ], style={"minWidth": "220px"}),

            # Edit form (right side)
            html.Div(id="edit-form", style={"flex": "1"}, children=[
                html.P("Select an annotation to edit it.", style={"color": "#aaa", "fontSize": "13px"}),
            ]),
        ]),
    ], style={
        "marginTop": "14px", "padding": "14px",
        "border": "1px solid #e5e7eb", "borderRadius": "8px",
        "background": "#fafafa",
    }),

    # Hidden stores
    dcc.Store(id="overlays-store", data=initial_overlays),
], style={"padding": "20px"})


# ── Build / update the annotation list & sync overlay store ──────────────────

@callback(
    Output("portal-flow", "viewportOverlays"),
    Output("overlays-store", "data"),
    Output("annotation-selector", "options"),
    Input("btn-add-overlay",   "n_clicks"),
    Input("btn-clear-overlays","n_clicks"),
    State("overlays-store", "data"),
    prevent_initial_call=True,
)
def add_or_clear(add_clicks, clear_clicks, current):
    from dash import ctx
    import random

    if ctx.triggered_id == "btn-clear-overlays":
        return [], [], []

    current = current or []
    idx = len(current) + 1
    new_overlay = {
        "x": random.randint(-100, 500),
        "y": random.randint(-50, 320),
        "content": f"📌 Note #{idx}",
        "style": {
            "background": "rgba(139,92,246,0.1)", "border": "1px solid rgba(139,92,246,0.3)",
            "padding": "4px 10px", "borderRadius": "6px",
            "fontSize": "11px", "color": "#7c3aed", "fontWeight": "500", "whiteSpace": "nowrap",
        },
    }
    updated = current + [new_overlay]
    opts = _build_options(updated)
    return updated, updated, opts


# ── Populate the edit form when an annotation is selected ────────────────────

@callback(
    Output("edit-form", "children"),
    Input("annotation-selector", "value"),
    State("overlays-store", "data"),
)
def show_edit_form(selected_idx, overlays):
    if selected_idx is None or not overlays:
        return html.P("Select an annotation to edit it.", style={"color": "#aaa", "fontSize": "13px"})

    o = overlays[selected_idx]
    field = {"width": "80px", "padding": "5px 8px", "border": "1px solid #ddd",
             "borderRadius": "5px", "fontSize": "13px"}
    content_field = {**field, "width": "260px"}

    return html.Div([
        html.Div([
            html.Label("X position:", style={"fontSize": "12px", "color": "#666", "marginRight": "6px"}),
            dcc.Input(id="edit-x",       type="number", value=o["x"],       style=field, debounce=True),
            html.Label("Y position:", style={"fontSize": "12px", "color": "#666", "margin": "0 6px 0 14px"}),
            dcc.Input(id="edit-y",       type="number", value=o["y"],       style=field, debounce=True),
        ], style={"display": "flex", "alignItems": "center", "marginBottom": "8px"}),
        html.Div([
            html.Label("Content:", style={"fontSize": "12px", "color": "#666", "marginRight": "6px"}),
            dcc.Input(id="edit-content", type="text",   value=o["content"], style=content_field, debounce=True),
        ], style={"display": "flex", "alignItems": "center", "marginBottom": "8px"}),
        html.Button("💾 Apply", id="btn-apply-edit", style={
            "padding": "7px 18px", "border": "none", "borderRadius": "6px",
            "background": "#3b82f6", "color": "white", "cursor": "pointer", "fontSize": "13px",
        }),
        html.Div(id="edit-feedback", style={"marginTop": "6px", "fontSize": "12px", "color": "#16a34a"}),
    ])


# Placeholder outputs so Dash doesn't complain about missing IDs on initial load
app.layout.children.append(html.Div([
    dcc.Input(id="edit-x",       style={"display": "none"}),
    dcc.Input(id="edit-y",       style={"display": "none"}),
    dcc.Input(id="edit-content", style={"display": "none"}),
    html.Button(id="btn-apply-edit", style={"display": "none"}),
    html.Div(id="edit-feedback"),
], id="_hidden-edit-ids", style={"display": "none"}))


# ── Apply edits ───────────────────────────────────────────────────────────────

@callback(
    Output("portal-flow",          "viewportOverlays", allow_duplicate=True),
    Output("overlays-store",       "data",             allow_duplicate=True),
    Output("annotation-selector",  "options",          allow_duplicate=True),
    Output("edit-feedback",        "children"),
    Input("btn-apply-edit",        "n_clicks"),
    State("annotation-selector",   "value"),
    State("edit-x",                "value"),
    State("edit-y",                "value"),
    State("edit-content",          "value"),
    State("overlays-store",        "data"),
    prevent_initial_call=True,
)
def apply_edit(n, selected_idx, x, y, content, overlays):
    if selected_idx is None or not overlays:
        return dash.no_update, dash.no_update, dash.no_update, "Nothing to update."

    updated = [dict(o) for o in overlays]
    updated[selected_idx] = {
        **updated[selected_idx],
        "x": x if x is not None else updated[selected_idx]["x"],
        "y": y if y is not None else updated[selected_idx]["y"],
        "content": content if content is not None else updated[selected_idx]["content"],
    }
    opts = _build_options(updated)
    return updated, updated, opts, f"✓ Annotation [{selected_idx}] updated."


# ── Sync selector options when store resets to initial overlays ──────────────

@callback(
    Output("annotation-selector", "options", allow_duplicate=True),
    Input("overlays-store", "data"),
    prevent_initial_call=True,
)
def sync_options(overlays):
    return _build_options(overlays or [])


if __name__ == "__main__":
    app.run(debug=True, port=8034)
```

    :defaultExpanded: false
    :withExpandedButton: true

**How it works**

- `viewportOverlays` — a list of `{x, y, content, style}` dicts rendered via `ViewportPortal`, anchored to flow coordinates rather than screen pixels
- Adding an annotation appends a new overlay dict with randomized coordinates and re-renders the list
- Selecting an annotation from the `RadioItems` list populates an inline edit form (`x`, `y`, `content` inputs) sourced from a `dcc.Store` holding the current overlay list
- Hidden placeholder inputs mirror the edit-form IDs so the "Apply" callback's `State` references resolve even before an annotation has ever been selected
- Because overlays are plain data, they can be generated, persisted, or restored exactly like nodes and edges

#### Sub-flows

Collapsible group nodes: double-click a group (or use the toggle buttons) to collapse it to a compact box and hide its children, while edges to/from the group stay connected.



```python
# File: examples/35_subflows.py

"""
Example 35: Sub-flows (Collapsible Groups)
===========================================
Demonstrates expandable/collapsible group nodes.

Architecture pattern for React Flow sub-flows:
- Edges that cross group boundaries connect to the GROUP node, not to children.
- Edges inside a group connect child-to-child (these are intra-group and safe).
- When a group collapses: children + their internal edges are hidden; the group
  shrinks to a compact box; external edges remain connected at the group level.

Double-click a group or use the buttons to toggle collapse/expand.
"""

import dash
from dash import html, callback, Input, Output, State
import dash_flows

app = dash.Dash(__name__)

# ── Initial graph ─────────────────────────────────────────────────────────────
#
#   source ──► [group-ingest: parse ──► validate] ──► [group-process: transform ──► enrich] ──► sink
#
# External edges connect to GROUP nodes (not directly to children).
# Internal edges connect children within the same group (safe pattern).

initial_nodes = [
    # External nodes
    {
        "id": "source",
        "type": "input",
        "data": {"label": "Data Source"},
        "position": {"x": 50, "y": 190},
    },

    # ── Ingestion group ────────────────────────────────────────────────────────
    {
        "id": "group-ingest",
        "type": "group",
        "data": {
            "label": "Ingestion Pipeline",
            "collapsedWidth": 200,
            "collapsedHeight": 52,
        },
        "position": {"x": 260, "y": 60},
        "style": {"width": 280, "height": 270},
    },
    {
        "id": "parse",
        "type": "default",
        "data": {"label": "Parse", "sublabel": "Deserialize"},
        "position": {"x": 50, "y": 55},
        "parentId": "group-ingest",
        "extent": "parent",
    },
    {
        "id": "validate",
        "type": "default",
        "data": {"label": "Validate", "sublabel": "Schema check"},
        "position": {"x": 50, "y": 170},
        "parentId": "group-ingest",
        "extent": "parent",
    },

    # ── Processing group ───────────────────────────────────────────────────────
    {
        "id": "group-process",
        "type": "group",
        "data": {
            "label": "Processing",
            "collapsedWidth": 200,
            "collapsedHeight": 52,
        },
        "position": {"x": 680, "y": 60},
        "style": {"width": 280, "height": 270},
    },
    {
        "id": "transform",
        "type": "default",
        "data": {"label": "Transform", "sublabel": "Normalize"},
        "position": {"x": 50, "y": 55},
        "parentId": "group-process",
        "extent": "parent",
    },
    {
        "id": "enrich",
        "type": "default",
        "data": {"label": "Enrich", "sublabel": "Add metadata"},
        "position": {"x": 50, "y": 170},
        "parentId": "group-process",
        "extent": "parent",
    },

    # External nodes
    {
        "id": "sink",
        "type": "output",
        "data": {"label": "Data Sink"},
        "position": {"x": 1100, "y": 190},
    },
]

initial_edges = [
    # External → Group  (NOT external → child inside group — avoids handle timing issues)
    {"id": "e-src-ingest",    "source": "source",        "target": "group-ingest",  "animated": True},

    # Intra-group edges (child → child within the SAME group — always safe)
    {"id": "e-parse-validate",    "source": "parse",     "target": "validate"},
    {"id": "e-transform-enrich",  "source": "transform", "target": "enrich"},

    # Group → Group  (cross-group at group level — safe)
    {"id": "e-ingest-process", "source": "group-ingest", "target": "group-process", "animated": True},

    # Group → External
    {"id": "e-process-sink",   "source": "group-process", "target": "sink",         "animated": True},
]

# ── Layout ────────────────────────────────────────────────────────────────────
btn_style = {
    "padding": "8px 16px",
    "border": "1px solid #ddd",
    "borderRadius": "6px",
    "background": "#fff",
    "cursor": "pointer",
    "fontSize": "13px",
}

app.layout = html.Div([
    html.H2("Sub-flows — Collapsible Groups"),

    html.Div([
        html.Strong("Pattern: ", style={"fontSize": "13px"}),
        html.Span(
            "External edges connect to the group container. "
            "Internal edges connect children within the same group. "
            "Collapsing hides children and shrinks the group; external connections stay intact.",
            style={"fontSize": "13px", "color": "#555"},
        ),
    ], style={
        "background": "#f0f7ff", "border": "1px solid #bfdbfe",
        "borderRadius": "8px", "padding": "10px 14px", "marginBottom": "10px",
    }),

    html.Div([
        html.Button("Toggle Ingestion",  id="btn-toggle-ingest",  style=btn_style),
        html.Button("Toggle Processing", id="btn-toggle-process", style=btn_style),
        html.Button("Expand All",        id="btn-expand-all",     style=btn_style),
        html.Span(id="collapse-info", style={
            "padding": "8px 16px", "background": "#f0f0f0",
            "borderRadius": "6px", "fontSize": "12px", "color": "#666",
        }),
    ], style={"display": "flex", "gap": "8px", "alignItems": "center", "marginBottom": "10px"}),

    dash_flows.DashFlows(
        id="subflow",
        nodes=initial_nodes,
        edges=initial_edges,
        fitView=True,
        showControls=True,
        showMiniMap=True,
        showBackground=True,
        style={"height": "520px"},
    ),
], style={"padding": "20px"})


# ── Callbacks ─────────────────────────────────────────────────────────────────

@callback(
    Output("subflow", "toggleCollapseNode"),
    Input("btn-toggle-ingest",  "n_clicks"),
    Input("btn-toggle-process", "n_clicks"),
    Input("subflow",            "doubleClickedNode"),
    prevent_initial_call=True,
)
def toggle_group(ingest, process, dbl_clicked):
    from dash import ctx
    if ctx.triggered_id == "btn-toggle-ingest":
        return "group-ingest"
    if ctx.triggered_id == "btn-toggle-process":
        return "group-process"
    if ctx.triggered_id == "subflow" and dbl_clicked:
        node_id = dbl_clicked.get("id", "")
        if node_id.startswith("group-"):
            return node_id
    return dash.no_update


@callback(
    Output("subflow", "nodes"),
    Input("btn-expand-all", "n_clicks"),
    State("subflow", "nodes"),
    prevent_initial_call=True,
)
def expand_all(n_clicks, nodes):
    """Force-expand all groups by clearing collapsed state."""
    if not nodes:
        return dash.no_update
    updated = []
    for n in nodes:
        if n.get("type") == "group" and n.get("data", {}).get("collapsed"):
            n = dict(n)
            n["data"] = {**n["data"], "collapsed": False}
            # Restore original size from initial_nodes
            orig = next((x for x in initial_nodes if x["id"] == n["id"]), None)
            if orig and orig.get("style"):
                n["style"] = orig["style"]
            n["hidden"] = False
        elif n.get("hidden"):
            n = dict(n)
            n["hidden"] = False
        updated.append(n)
    return updated


@callback(
    Output("collapse-info", "children"),
    Input("subflow", "collapsedGroups"),
)
def show_collapsed(groups):
    if not groups:
        return "All groups expanded"
    return f"Collapsed: {', '.join(groups)}"


if __name__ == "__main__":
    app.run(debug=True, port=8035)
```

    :defaultExpanded: false
    :withExpandedButton: true

**How it works**

- `toggleCollapseNode` — set to a group node's `id` to flip its collapsed/expanded state
- `collapsedGroups` — an output prop listing the IDs of currently-collapsed groups, shown in the status line
- `data.collapsedWidth` / `data.collapsedHeight` — the size a group shrinks to once collapsed
- Architecture pattern: edges that cross a group boundary connect to the **group node**, not to a child inside it; edges between two children of the same group connect child-to-child directly
- `doubleClickedNode` — lets users collapse/expand by double-clicking the group header, in addition to the toggle buttons


---

*Source: /advanced*
