anywidget-graph 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ """Interactive graph visualization for Python notebooks."""
2
+
3
+ from anywidget_graph.widget import Graph
4
+
5
+ __all__ = ["Graph"]
6
+ __version__ = "0.1.0"
File without changes
@@ -0,0 +1,293 @@
1
+ """Main Graph widget using anywidget and Sigma.js."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ import anywidget
8
+ import traitlets
9
+
10
+ if TYPE_CHECKING:
11
+ from collections.abc import Callable
12
+
13
+ _ESM = """
14
+ import Graph from "https://esm.sh/graphology@0.25.4";
15
+ import Sigma from "https://esm.sh/sigma@3.0.0";
16
+
17
+ function render({ model, el }) {
18
+ const container = document.createElement("div");
19
+ container.style.width = model.get("width") + "px";
20
+ container.style.height = model.get("height") + "px";
21
+ container.style.border = "1px solid #ddd";
22
+ container.style.borderRadius = "4px";
23
+ container.style.background = model.get("background") || "#fafafa";
24
+ el.appendChild(container);
25
+
26
+ const graph = new Graph();
27
+
28
+ const nodes = model.get("nodes") || [];
29
+ nodes.forEach((node) => {
30
+ graph.addNode(node.id, {
31
+ label: node.label || node.id,
32
+ x: node.x ?? Math.random() * 100,
33
+ y: node.y ?? Math.random() * 100,
34
+ size: node.size || 10,
35
+ color: node.color || "#6366f1",
36
+ });
37
+ });
38
+
39
+ const edges = model.get("edges") || [];
40
+ edges.forEach((edge, i) => {
41
+ graph.addEdge(edge.source, edge.target, {
42
+ label: edge.label || "",
43
+ size: edge.size || 2,
44
+ color: edge.color || "#94a3b8",
45
+ });
46
+ });
47
+
48
+ const renderer = new Sigma(graph, container, {
49
+ renderLabels: model.get("show_labels"),
50
+ renderEdgeLabels: model.get("show_edge_labels"),
51
+ defaultNodeColor: "#6366f1",
52
+ defaultEdgeColor: "#94a3b8",
53
+ labelColor: { color: "#333" },
54
+ labelSize: 12,
55
+ labelWeight: "500",
56
+ });
57
+
58
+ renderer.on("clickNode", ({ node }) => {
59
+ const nodeData = graph.getNodeAttributes(node);
60
+ model.set("selected_node", { id: node, ...nodeData });
61
+ model.save_changes();
62
+ });
63
+
64
+ renderer.on("clickEdge", ({ edge }) => {
65
+ const edgeData = graph.getEdgeAttributes(edge);
66
+ const [source, target] = graph.extremities(edge);
67
+ model.set("selected_edge", { source, target, ...edgeData });
68
+ model.save_changes();
69
+ });
70
+
71
+ renderer.on("clickStage", () => {
72
+ model.set("selected_node", null);
73
+ model.set("selected_edge", null);
74
+ model.save_changes();
75
+ });
76
+
77
+ model.on("change:nodes", () => {
78
+ graph.clear();
79
+ const newNodes = model.get("nodes") || [];
80
+ newNodes.forEach((node) => {
81
+ graph.addNode(node.id, {
82
+ label: node.label || node.id,
83
+ x: node.x ?? Math.random() * 100,
84
+ y: node.y ?? Math.random() * 100,
85
+ size: node.size || 10,
86
+ color: node.color || "#6366f1",
87
+ });
88
+ });
89
+ const newEdges = model.get("edges") || [];
90
+ newEdges.forEach((edge) => {
91
+ graph.addEdge(edge.source, edge.target, {
92
+ label: edge.label || "",
93
+ size: edge.size || 2,
94
+ color: edge.color || "#94a3b8",
95
+ });
96
+ });
97
+ renderer.refresh();
98
+ });
99
+
100
+ model.on("change:edges", () => {
101
+ graph.clearEdges();
102
+ const newEdges = model.get("edges") || [];
103
+ newEdges.forEach((edge) => {
104
+ graph.addEdge(edge.source, edge.target, {
105
+ label: edge.label || "",
106
+ size: edge.size || 2,
107
+ color: edge.color || "#94a3b8",
108
+ });
109
+ });
110
+ renderer.refresh();
111
+ });
112
+
113
+ return () => {
114
+ renderer.kill();
115
+ };
116
+ }
117
+
118
+ export default { render };
119
+ """
120
+
121
+ _CSS = """
122
+ .anywidget-graph {
123
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
124
+ }
125
+ """
126
+
127
+
128
+ class Graph(anywidget.AnyWidget):
129
+ """Interactive graph visualization widget using Sigma.js."""
130
+
131
+ _esm = _ESM
132
+ _css = _CSS
133
+
134
+ nodes = traitlets.List(trait=traitlets.Dict()).tag(sync=True)
135
+ edges = traitlets.List(trait=traitlets.Dict()).tag(sync=True)
136
+
137
+ width = traitlets.Int(default_value=800).tag(sync=True)
138
+ height = traitlets.Int(default_value=600).tag(sync=True)
139
+ background = traitlets.Unicode(default_value="#fafafa").tag(sync=True)
140
+ show_labels = traitlets.Bool(default_value=True).tag(sync=True)
141
+ show_edge_labels = traitlets.Bool(default_value=False).tag(sync=True)
142
+
143
+ selected_node = traitlets.Dict(allow_none=True, default_value=None).tag(sync=True)
144
+ selected_edge = traitlets.Dict(allow_none=True, default_value=None).tag(sync=True)
145
+
146
+ def __init__(
147
+ self,
148
+ nodes: list[dict[str, Any]] | None = None,
149
+ edges: list[dict[str, Any]] | None = None,
150
+ **kwargs: Any,
151
+ ) -> None:
152
+ super().__init__(nodes=nodes or [], edges=edges or [], **kwargs)
153
+ self._node_click_callbacks: list[Callable] = []
154
+ self._edge_click_callbacks: list[Callable] = []
155
+
156
+ @classmethod
157
+ def from_dict(cls, data: dict[str, Any], **kwargs: Any) -> Graph:
158
+ """Create a Graph from a dictionary with nodes and edges keys."""
159
+ return cls(nodes=data.get("nodes", []), edges=data.get("edges", []), **kwargs)
160
+
161
+ @classmethod
162
+ def from_cypher(cls, result: Any, **kwargs: Any) -> Graph:
163
+ """Create a Graph from Cypher query results."""
164
+ nodes: dict[str, dict] = {}
165
+ edges: list[dict] = []
166
+
167
+ records = list(result) if hasattr(result, "__iter__") else [result]
168
+
169
+ for record in records:
170
+ if hasattr(record, "items"):
171
+ items = record.items() if callable(record.items) else record.items
172
+ elif hasattr(record, "data"):
173
+ items = record.data().items()
174
+ else:
175
+ items = record.items() if isinstance(record, dict) else []
176
+
177
+ for key, value in items:
178
+ if _is_node(value):
179
+ node_id = _get_node_id(value)
180
+ if node_id not in nodes:
181
+ nodes[node_id] = _node_to_dict(value)
182
+ elif _is_relationship(value):
183
+ edges.append(_relationship_to_dict(value))
184
+
185
+ return cls(nodes=list(nodes.values()), edges=edges, **kwargs)
186
+
187
+ def on_node_click(self, callback: Callable[[str, dict], None]) -> Callable:
188
+ """Register a callback for node click events."""
189
+ self._node_click_callbacks.append(callback)
190
+
191
+ def observer(change: dict) -> None:
192
+ if change["new"] is not None:
193
+ node_data = change["new"].copy()
194
+ node_id = node_data.pop("id", None)
195
+ for cb in self._node_click_callbacks:
196
+ cb(node_id, node_data)
197
+
198
+ self.observe(observer, names=["selected_node"])
199
+ return callback
200
+
201
+ def on_edge_click(self, callback: Callable[[dict], None]) -> Callable:
202
+ """Register a callback for edge click events."""
203
+ self._edge_click_callbacks.append(callback)
204
+
205
+ def observer(change: dict) -> None:
206
+ if change["new"] is not None:
207
+ for cb in self._edge_click_callbacks:
208
+ cb(change["new"])
209
+
210
+ self.observe(observer, names=["selected_edge"])
211
+ return callback
212
+
213
+
214
+ def _is_node(obj: Any) -> bool:
215
+ if hasattr(obj, "labels") and hasattr(obj, "element_id"):
216
+ return True
217
+ if hasattr(obj, "labels") and hasattr(obj, "properties"):
218
+ return True
219
+ if isinstance(obj, dict) and "id" in obj:
220
+ return True
221
+ return False
222
+
223
+
224
+ def _is_relationship(obj: Any) -> bool:
225
+ if hasattr(obj, "type") and hasattr(obj, "start_node"):
226
+ return True
227
+ if hasattr(obj, "type") and hasattr(obj, "source") and hasattr(obj, "target"):
228
+ return True
229
+ if isinstance(obj, dict) and "source" in obj and "target" in obj:
230
+ return True
231
+ return False
232
+
233
+
234
+ def _get_node_id(node: Any) -> str:
235
+ if hasattr(node, "element_id"):
236
+ return str(node.element_id)
237
+ if hasattr(node, "id"):
238
+ return str(node.id)
239
+ if isinstance(node, dict):
240
+ return str(node.get("id", id(node)))
241
+ return str(id(node))
242
+
243
+
244
+ def _node_to_dict(node: Any) -> dict:
245
+ result: dict[str, Any] = {"id": _get_node_id(node)}
246
+
247
+ if hasattr(node, "labels"):
248
+ labels = list(node.labels) if hasattr(node.labels, "__iter__") else [node.labels]
249
+ if labels:
250
+ result["label"] = labels[0]
251
+ result["labels"] = labels
252
+
253
+ if hasattr(node, "properties"):
254
+ props = node.properties if isinstance(node.properties, dict) else dict(node.properties)
255
+ result.update(props)
256
+ elif hasattr(node, "items"):
257
+ result.update(dict(node))
258
+ elif isinstance(node, dict):
259
+ result.update(node)
260
+
261
+ if "label" not in result and "name" in result:
262
+ result["label"] = result["name"]
263
+
264
+ return result
265
+
266
+
267
+ def _relationship_to_dict(rel: Any) -> dict:
268
+ result: dict[str, Any] = {}
269
+
270
+ if hasattr(rel, "start_node") and hasattr(rel, "end_node"):
271
+ result["source"] = _get_node_id(rel.start_node)
272
+ result["target"] = _get_node_id(rel.end_node)
273
+ elif hasattr(rel, "source") and hasattr(rel, "target"):
274
+ result["source"] = _get_node_id(rel.source)
275
+ result["target"] = _get_node_id(rel.target)
276
+ elif isinstance(rel, dict):
277
+ result["source"] = str(rel.get("source", ""))
278
+ result["target"] = str(rel.get("target", ""))
279
+
280
+ if hasattr(rel, "type"):
281
+ result["label"] = str(rel.type)
282
+ elif isinstance(rel, dict) and "type" in rel:
283
+ result["label"] = str(rel["type"])
284
+ elif isinstance(rel, dict) and "label" in rel:
285
+ result["label"] = str(rel["label"])
286
+
287
+ if hasattr(rel, "properties"):
288
+ props = rel.properties if isinstance(rel.properties, dict) else dict(rel.properties)
289
+ result.update(props)
290
+ elif hasattr(rel, "items") and not isinstance(rel, dict):
291
+ result.update(dict(rel))
292
+
293
+ return result
@@ -0,0 +1,258 @@
1
+ Metadata-Version: 2.4
2
+ Name: anywidget-graph
3
+ Version: 0.1.0
4
+ Summary: Interactive graph visualization for Python notebooks using anywidget
5
+ Project-URL: Homepage, https://grafeo.dev/
6
+ Project-URL: Repository, https://github.com/GrafeoDB/anywidget-graph
7
+ Author-email: "S.T. Grond" <widget@grafeo.dev>
8
+ License: Apache-2.0
9
+ Keywords: anywidget,cypher,graph,jupyter,marimo,neo4j,visualization
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Framework :: Jupyter
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: License :: OSI Approved :: Apache Software License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: Scientific/Engineering :: Visualization
20
+ Requires-Python: >=3.12
21
+ Requires-Dist: anywidget>=0.9.21
22
+ Provides-Extra: dev
23
+ Requires-Dist: marimo>=0.19.7; extra == 'dev'
24
+ Requires-Dist: prek>=0.3.1; extra == 'dev'
25
+ Requires-Dist: pytest>=9.0.2; extra == 'dev'
26
+ Requires-Dist: ruff>=0.14.14; extra == 'dev'
27
+ Requires-Dist: ty>=0.0.14; extra == 'dev'
28
+ Provides-Extra: networkx
29
+ Requires-Dist: networkx>=3.0; extra == 'networkx'
30
+ Provides-Extra: pandas
31
+ Requires-Dist: pandas>=2.0; extra == 'pandas'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # anywidget-graph
35
+
36
+ Interactive graph visualization for Python notebooks.
37
+
38
+ Works with Marimo, Jupyter, VS Code, Colab, anywhere [anywidget](https://anywidget.dev/) runs.
39
+
40
+ ## Features
41
+
42
+ - **Universal** — One widget, every notebook environment
43
+ - **Backend-agnostic** — Grafeo, Neo4j, NetworkX, pandas, or raw dicts
44
+ - **Interactive** — Pan, zoom, click, expand neighbors, select paths
45
+ - **Customizable** — Colors, sizes, shapes, layouts
46
+ - **Performant** — Virtualized rendering for large graphs
47
+ - **Exportable** — PNG, SVG, JSON
48
+
49
+ ## Installation
50
+
51
+ ```bash
52
+ uv add anywidget-graph
53
+ ```
54
+
55
+ ## Quick Start
56
+
57
+ ```python
58
+ from anywidget_graph import Graph
59
+
60
+ graph = Graph.from_dict({
61
+ "nodes": [
62
+ {"id": "alice", "label": "Alice", "group": "person"},
63
+ {"id": "bob", "label": "Bob", "group": "person"},
64
+ {"id": "paper", "label": "Graph Theory", "group": "document"},
65
+ ],
66
+ "edges": [
67
+ {"source": "alice", "target": "bob", "label": "knows"},
68
+ {"source": "alice", "target": "paper", "label": "authored"},
69
+ ]
70
+ })
71
+
72
+ graph
73
+ ```
74
+
75
+ ## Data Sources
76
+
77
+ ### Dictionary
78
+
79
+ ```python
80
+ from anywidget_graph import Graph
81
+
82
+ graph = Graph.from_dict({
83
+ "nodes": [{"id": "a"}, {"id": "b"}],
84
+ "edges": [{"source": "a", "target": "b"}]
85
+ })
86
+ ```
87
+
88
+ ### Grafeo
89
+
90
+ ```python
91
+ from grafeo import GrafeoDB
92
+ from anywidget_graph import Graph
93
+
94
+ db = GrafeoDB()
95
+ db.execute("INSERT (:Person {name: 'Alice'})-[:KNOWS]->(:Person {name: 'Bob'})")
96
+
97
+ result = db.execute("MATCH (a)-[r]->(b) RETURN a, r, b")
98
+ graph = Graph.from_grafeo(result)
99
+ ```
100
+
101
+ ### Neo4j
102
+
103
+ ```python
104
+ from neo4j import GraphDatabase
105
+ from anywidget_graph import Graph
106
+
107
+ driver = GraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "password"))
108
+
109
+ with driver.session() as session:
110
+ result = session.run("MATCH (a)-[r]->(b) RETURN a, r, b LIMIT 100")
111
+ graph = Graph.from_neo4j(result)
112
+ ```
113
+
114
+ ### NetworkX
115
+
116
+ ```python
117
+ import networkx as nx
118
+ from anywidget_graph import Graph
119
+
120
+ G = nx.karate_club_graph()
121
+ graph = Graph.from_networkx(G)
122
+ ```
123
+
124
+ ### pandas
125
+
126
+ ```python
127
+ import pandas as pd
128
+ from anywidget_graph import Graph
129
+
130
+ edges = pd.DataFrame({
131
+ "source": ["alice", "alice", "bob"],
132
+ "target": ["bob", "carol", "carol"],
133
+ "weight": [1.0, 0.5, 0.8]
134
+ })
135
+
136
+ graph = Graph.from_pandas(edges)
137
+ ```
138
+
139
+ ## Interactivity
140
+
141
+ ### Events
142
+
143
+ ```python
144
+ graph = Graph.from_dict(data)
145
+
146
+ @graph.on_node_click
147
+ def handle_node(node_id, node_data):
148
+ print(f"Clicked: {node_id}")
149
+
150
+ @graph.on_edge_click
151
+ def handle_edge(edge_id, edge_data):
152
+ print(f"Edge: {edge_data['label']}")
153
+ ```
154
+
155
+ ### Selection
156
+
157
+ ```python
158
+ graph.selected_nodes # Get current selection
159
+ graph.select(["alice"]) # Select nodes
160
+ graph.clear_selection() # Clear
161
+ ```
162
+
163
+ ### Expansion
164
+
165
+ ```python
166
+ graph.expand("alice") # Show neighbors
167
+ graph.collapse("alice") # Hide neighbors
168
+ ```
169
+
170
+ ## Styling
171
+
172
+ ### By Group
173
+
174
+ ```python
175
+ graph = Graph.from_dict(
176
+ data,
177
+ node_styles={
178
+ "person": {"color": "#4CAF50", "size": 30},
179
+ "document": {"color": "#2196F3", "shape": "square"},
180
+ }
181
+ )
182
+ ```
183
+
184
+ ### By Property
185
+
186
+ ```python
187
+ graph = Graph.from_dict(
188
+ data,
189
+ node_color="group", # Color by field
190
+ node_size=lambda n: n["score"] * 10, # Size by function
191
+ edge_width="weight", # Width by field
192
+ )
193
+ ```
194
+
195
+ ### Layouts
196
+
197
+ ```python
198
+ Graph.from_dict(data, layout="force") # Default
199
+ Graph.from_dict(data, layout="hierarchical")
200
+ Graph.from_dict(data, layout="circular")
201
+ Graph.from_dict(data, layout="grid")
202
+ ```
203
+
204
+ ## Options
205
+
206
+ ```python
207
+ graph = Graph.from_dict(
208
+ data,
209
+ width=800,
210
+ height=600,
211
+ directed=True,
212
+ labels=True,
213
+ edge_labels=False,
214
+ physics=True,
215
+ zoom=(0.1, 4),
216
+ )
217
+ ```
218
+
219
+ ## Large Graphs
220
+
221
+ For 1000+ nodes:
222
+
223
+ ```python
224
+ graph = Graph.from_dict(
225
+ data,
226
+ virtualize=True,
227
+ cluster=True,
228
+ )
229
+ ```
230
+
231
+ ## Export
232
+
233
+ ```python
234
+ graph.to_png("graph.png")
235
+ graph.to_svg("graph.svg")
236
+ graph.to_json("graph.json")
237
+ ```
238
+
239
+ ## Environment Support
240
+
241
+ | Environment | Supported |
242
+ |-------------|-----------|
243
+ | Marimo | ✅ |
244
+ | JupyterLab | ✅ |
245
+ | Jupyter Notebook | ✅ |
246
+ | VS Code | ✅ |
247
+ | Google Colab | ✅ |
248
+ | Databricks | ✅ |
249
+
250
+ ## Related
251
+
252
+ - [anywidget](https://anywidget.dev/) — Custom Jupyter widgets made easy
253
+ - [Grafeo](https://github.com/GrafeoDB/grafeo) — Embeddable graph database
254
+ - [grafeo-wasm](https://github.com/GrafeoDB/grafeo-wasm) — Grafeo in the browser
255
+
256
+ ## License
257
+
258
+ Apache-2.0
@@ -0,0 +1,6 @@
1
+ anywidget_graph/__init__.py,sha256=uPbg0OSg6iO8dySwupCzx6zc0LWjsjQcnr-VgLQTSEM,145
2
+ anywidget_graph/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ anywidget_graph/widget.py,sha256=HRcbtmJLnOWzrusKy7Ikedoe_HryNJmDHgK4HK3rCYc,9444
4
+ anywidget_graph-0.1.0.dist-info/METADATA,sha256=RP9R6XK4DQkarK-GXsM-9s6hiR7mBElLgv7TSEFL0Q0,5747
5
+ anywidget_graph-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
6
+ anywidget_graph-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any