Metadata-Version: 2.5
Name: anywidget-graph
Version: 0.4.0
Summary: Interactive graph visualization for Python notebooks using anywidget
Project-URL: Homepage, https://grafeo.dev/
Project-URL: Repository, https://github.com/GrafeoDB/anywidget-graph
Author-email: "S.T. Grond" <dev@grafeo.dev>
License-Expression: Apache-2.0
License-File: LICENSE
Keywords: anywidget,cypher,graph,jupyter,marimo,neo4j,visualization
Classifier: Development Status :: 3 - Alpha
Classifier: Framework :: Jupyter
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Science/Research
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Scientific/Engineering :: Visualization
Requires-Python: >=3.12
Requires-Dist: anywidget>=0.9.21
Requires-Dist: marimo>=0.19.9
Provides-Extra: cosmosdb
Requires-Dist: gremlinpython>=3.7; extra == 'cosmosdb'
Provides-Extra: demo
Provides-Extra: dev
Requires-Dist: marimo>=0.20; extra == 'dev'
Requires-Dist: prek>=0.3; extra == 'dev'
Requires-Dist: pytest>=9.0.2; extra == 'dev'
Requires-Dist: ruff>=0.15; extra == 'dev'
Requires-Dist: ty>=0.0; extra == 'dev'
Provides-Extra: embed
Provides-Extra: grafeo
Requires-Dist: grafeo<0.7,>=0.5.44; extra == 'grafeo'
Provides-Extra: networkx
Requires-Dist: networkx>=3.0; extra == 'networkx'
Provides-Extra: pandas
Requires-Dist: pandas>=2.0; extra == 'pandas'
Description-Content-Type: text/markdown

# anywidget-graph

Interactive graph visualization for Python notebooks.

Works with Marimo, Jupyter, VS Code, Colab, anywhere [anywidget](https://anywidget.dev/) runs.

![anywidget-graph demo](assets/demo.png)

## Features

- **Universal**: One widget, every notebook environment
- **Backend-agnostic**: Grafeo, Neo4j, NetworkX, pandas, or raw dicts
- **Interactive**: Pan, zoom, click, drag, pin, expand neighbors, box select
- **Customizable**: Colors, sizes, layouts, dark mode
- **Exportable**: HTML, JSON

## Installation

```bash
uv add anywidget-graph
```

Optional extras:

```bash
uv add "anywidget-graph[networkx]"   # NetworkX support
uv add "anywidget-graph[pandas]"     # pandas support
uv add "anywidget-graph[grafeo]"     # Grafeo backend
uv add "anywidget-graph[cosmosdb]"   # CosmosDB / Gremlin support
```

## Quick Start

```python
from anywidget_graph import Graph

graph = Graph.from_dict({
    "nodes": [
        {"id": "alice", "label": "Alice", "group": "person"},
        {"id": "bob", "label": "Bob", "group": "person"},
        {"id": "paper", "label": "Graph Theory", "group": "document"},
    ],
    "edges": [
        {"source": "alice", "target": "bob", "label": "knows"},
        {"source": "alice", "target": "paper", "label": "authored"},
    ]
})

graph
```

## Data Sources

### Dictionary

```python
graph = Graph.from_dict({
    "nodes": [{"id": "a"}, {"id": "b"}],
    "edges": [{"source": "a", "target": "b"}]
})
```

### Direct initialization

```python
graph = Graph(
    nodes=[{"id": "a", "label": "Alice"}, {"id": "b", "label": "Bob"}],
    edges=[{"source": "a", "target": "b", "label": "KNOWS"}],
)
```

### Cypher results (Neo4j)

```python
from neo4j import GraphDatabase

driver = GraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "password"))

with driver.session() as session:
    result = session.run("MATCH (a)-[r]->(b) RETURN a, r, b LIMIT 100")
    graph = Graph.from_cypher(result)
```

### GQL results

```python
graph = Graph.from_gql(result)
```

### SPARQL results

```python
from rdflib import Graph as RDFGraph

g = RDFGraph()
g.parse("data.ttl")
result = g.query("SELECT ?s ?p ?o WHERE { ?s ?p ?o }")
graph = Graph.from_sparql(result)
```

### Gremlin results (CosmosDB, TinkerPop)

```python
graph = Graph.from_gremlin(result)
```

### GraphQL results

```python
graph = Graph.from_graphql(
    response.json(),
    nodes_path="data.characters.results",
    id_field="id",
    label_field="name",
)
```

### NetworkX

```python
import networkx as nx

G = nx.karate_club_graph()
graph = Graph.from_networkx(G)
```

### pandas DataFrames

```python
import pandas as pd

nodes_df = pd.DataFrame({"id": ["alice", "bob"], "group": ["person", "person"]})
edges_df = pd.DataFrame({"source": ["alice"], "target": ["bob"], "weight": [1.0]})

graph = Graph.from_dataframe(nodes_df, edges_df)
```

## Interactivity

### Events

```python
graph = Graph.from_dict(data)

@graph.on_node_click
def handle_node(node_id, node_data):
    print(f"Clicked: {node_id}")

@graph.on_edge_click
def handle_edge(edge_data):
    print(f"Edge: {edge_data['label']}")

@graph.on_selection
def handle_selection(node_ids):
    print(f"Selected: {node_ids}")
```

### Selection

```python
graph.selected_nodes            # Current selection (list of IDs)
graph.selection_mode = "box"    # Switch to box-select mode
```

### Node expansion

```python
graph.expand_node("alice")      # Fetch and merge neighbors (requires backend)
```

### Node pinning

```python
graph.pin_nodes(["alice", "bob"])   # Pin at current positions
graph.unpin_nodes(["alice"])        # Release back to layout
graph.toggle_pin("bob")            # Toggle pin state
graph.unpin_all()                   # Unpin everything
```

### Clear

```python
graph.clear()                   # Remove all nodes, edges, pins, and selection
```

### Linked lanes and live append

Show two graphs side by side in one canvas, each laid out in its own lane, with curved edges between them, and grow them while a process runs:

```python
graph = Graph(
    lanes=[{"id": "source", "title": "Source graph"}, {"id": "target", "title": "Target graph"}],
    nodes=[{"id": "a", "lane": "source"}, {"id": "b", "lane": "source"}, {"id": "x", "lane": "target"}],
    edges=[{"source": "a", "target": "b"}, {"source": "x", "target": "a", "cross": True}],
)
graph.append(nodes=[{"id": "y", "lane": "target"}], edges=[{"source": "y", "target": "b"}])
graph.pulse_nodes = ["b"]       # a soft pulse on these nodes; [] stops it
graph.theme = {"background": "#0d1416", "panel": "#121b1e", "text": "#dbe7e5", "muted": "#8ea3a3", "border": "#1e2b2f", "accent": "#5bb8a9"}
```

- `lanes`: lanes left to right; a node's `lane` picks one (a node without a known lane goes to the first). Edges between lanes (or marked `cross`) are drawn as soft curves above both lanes and light up when either end is hovered or selected. Without `lanes` the widget behaves as before.
- `append(nodes, edges)`: adds items without a re-layout; existing nodes keep their positions, a new node is placed at the height of its neighbours in other lanes (or next to its neighbours in its lane). New items fade in and edges draw from source to target, `append_stagger_ms` (default 60) apart; a large batch is kept under 1.5 s. `append_animation="none"` turns the animation off.
- Lane options: `width` is a lane's share of the standard width (default 1); `arrange: "column"` stacks the lane's nodes in the middle of its band (for a few roots, such as repositories, whose edges then fan out into the next lane); `action: True` makes a lane without nodes that shows a glyph button (`glyph`: a list of colours drawn as stacked bars, `caption`: text under it). The camera frames all lanes, and the lanes stretch to the canvas's shape.
- `lane_action`: set to `{"lane": <id>, "seq": <n>}` when an action lane's glyph is clicked, so the host can respond (open a panel, switch a view).
- `pulse_nodes`: ids that pulse softly.
- `theme`: host colours (`background`, `panel`, `text`, `muted`, `border`, `accent`) that replace the widget's light and dark defaults.
- `_features`: the widget sets the capabilities it supports when it renders, so a host can detect an older build.

## Styling

### Property-based coloring

```python
graph = Graph.from_dict(
    data,
    color_field="group",               # Color nodes by field
    color_scale="viridis",             # Scale: viridis, plasma, inferno, magma, cividis, turbo
    size_field="score",                # Size nodes by field
    size_range=[5, 30],                # Min/max node size
)
```

### Edge styling

```python
graph.edge_color_field = "type"
graph.edge_color_scale = "plasma"
graph.edge_size_field = "weight"
graph.edge_size_range = [1, 8]
```

### Layouts

```python
Graph.from_dict(data, layout="force")      # ForceAtlas2 (default)
Graph.from_dict(data, layout="circular")
Graph.from_dict(data, layout="random")
```

## Options

```python
graph = Graph(
    nodes=nodes,
    edges=edges,
    width=800,                  # Widget width (px)
    height=600,                 # Widget height (px)
    fill=False,                 # Take the host's size and follow resizes (ignores width/height)
    background="#fafafa",       # Background color
    show_labels=True,           # Node labels
    show_edge_labels=False,     # Edge labels
    show_toolbar=True,          # Toolbar visibility
    show_settings=True,         # Settings panel
    show_query_input=True,      # Query input box
    dark_mode=True,             # Dark theme
    show_tooltip=True,          # Hover tooltips
    tooltip_fields=["label", "id"],
    max_nodes=300,              # Limit for node expansion
)
```

## Database Backends

### Grafeo (default)

Grafeo runs in one of three modes, picked with `grafeo_connection_mode` or in the settings panel.

**Embedded** (Python, the default): queries run in the notebook kernel. Requires the `grafeo` extra (`grafeo>=0.5.44,<0.7`).

```python
import grafeo
db = grafeo.GrafeoDB()
graph = Graph(database_backend="grafeo", grafeo_db=db, query_language="gql")
```

**Server**: the browser talks to [grafeo-server](https://github.com/GrafeoDB/grafeo-server) over HTTP (`POST /query`, `GET /db/{name}/schema`). The server must allow the notebook's origin, for example `grafeo-server --cors-origins http://localhost:8888`.

```python
graph = Graph(database_backend="grafeo", grafeo_connection_mode="server", grafeo_server_url="http://localhost:7474")
```

**WASM**: the engine runs in the browser. `@grafeo-db/wasm` (0.5.44) is downloaded from jsDelivr when you click "Initialize WASM", so nothing extra is installed in Python.

```python
graph = Graph(database_backend="grafeo", grafeo_connection_mode="wasm", query_language="gql")
```

Query languages: GQL, Cypher, Gremlin, GraphQL and SPARQL. Nodes and edges are taken from every result column, including lists (`collect(n)`, variable-length relationships, `nodes(p)`) and paths (`RETURN p`); scalar columns are ignored. Run one statement per query: Grafeo rejects several `;`-separated statements in one call.

Try it without any setup:

```python
from anywidget_graph.demo import demo_graph
demo_graph()  # WASM mode with a small movie graph loaded
```

### Neo4j (browser-side)

```python
graph = Graph(
    database_backend="neo4j",
    connection_uri="neo4j+s://demo.neo4jlabs.com",
    connection_username="neo4j",
    connection_password="password",
)
```

### Generic backend

```python
graph = Graph(backend=my_backend)  # Any object implementing DatabaseBackend protocol
```

## Export

```python
graph.to_json()                         # JSON string with nodes and edges
graph.to_html()                         # Self-contained HTML string
graph.to_html(title="My Graph")         # Custom title
graph.save_html("graph.html")           # Write HTML to file
```

## Environment Support

| Environment | Supported |
|-------------|-----------|
| Marimo | Yes |
| JupyterLab | Yes |
| Jupyter Notebook | Yes |
| VS Code | Yes |
| Google Colab | Yes |
| Databricks | Yes |

## Related

- [anywidget](https://anywidget.dev/), custom Jupyter widgets made easy
- [Grafeo](https://github.com/GrafeoDB/grafeo), embeddable graph database
- [grafeo-web](https://github.com/GrafeoDB/grafeo-web), Grafeo in the browser
- [Playground](https://grafeo.ai), interactive browser playground for Grafeo

## License

Apache-2.0
