anywidget-graph 0.4.0__tar.gz → 0.4.2__tar.gz

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.
Files changed (62) hide show
  1. {anywidget_graph-0.4.0 → anywidget_graph-0.4.2}/CHANGELOG.md +55 -0
  2. anywidget_graph-0.4.2/PKG-INFO +423 -0
  3. anywidget_graph-0.4.2/README.md +382 -0
  4. {anywidget_graph-0.4.0 → anywidget_graph-0.4.2}/pyproject.toml +9 -9
  5. {anywidget_graph-0.4.0 → anywidget_graph-0.4.2}/src/anywidget_graph/__init__.py +1 -1
  6. {anywidget_graph-0.4.0 → anywidget_graph-0.4.2}/src/anywidget_graph/ui/__init__.py +4 -0
  7. anywidget_graph-0.4.2/src/anywidget_graph/ui/defaults.js +75 -0
  8. {anywidget_graph-0.4.0 → anywidget_graph-0.4.2}/src/anywidget_graph/ui/index.js +663 -101
  9. anywidget_graph-0.4.2/src/anywidget_graph/ui/lanes.js +588 -0
  10. {anywidget_graph-0.4.0 → anywidget_graph-0.4.2}/src/anywidget_graph/ui/schema.js +18 -17
  11. {anywidget_graph-0.4.0 → anywidget_graph-0.4.2}/src/anywidget_graph/ui/toolbar.js +6 -1
  12. {anywidget_graph-0.4.0 → anywidget_graph-0.4.2}/src/anywidget_graph/widget.py +52 -2
  13. anywidget_graph-0.4.2/tests/js/lanes.test.mjs +468 -0
  14. {anywidget_graph-0.4.0 → anywidget_graph-0.4.2}/tests/test_graph.py +57 -1
  15. {anywidget_graph-0.4.0 → anywidget_graph-0.4.2}/tests/test_ui.py +23 -0
  16. anywidget_graph-0.4.2/uv.lock +2003 -0
  17. anywidget_graph-0.4.0/PKG-INFO +0 -384
  18. anywidget_graph-0.4.0/README.md +0 -343
  19. anywidget_graph-0.4.0/src/anywidget_graph/ui/lanes.js +0 -189
  20. anywidget_graph-0.4.0/tests/js/lanes.test.mjs +0 -133
  21. anywidget_graph-0.4.0/uv.lock +0 -1582
  22. {anywidget_graph-0.4.0 → anywidget_graph-0.4.2}/.github/workflows/ci.yml +0 -0
  23. {anywidget_graph-0.4.0 → anywidget_graph-0.4.2}/.github/workflows/grafeo-latest.yml +0 -0
  24. {anywidget_graph-0.4.0 → anywidget_graph-0.4.2}/.github/workflows/pypi.yml +0 -0
  25. {anywidget_graph-0.4.0 → anywidget_graph-0.4.2}/.gitignore +0 -0
  26. {anywidget_graph-0.4.0 → anywidget_graph-0.4.2}/.python-version +0 -0
  27. {anywidget_graph-0.4.0 → anywidget_graph-0.4.2}/LICENSE +0 -0
  28. {anywidget_graph-0.4.0 → anywidget_graph-0.4.2}/assets/demo.png +0 -0
  29. {anywidget_graph-0.4.0 → anywidget_graph-0.4.2}/examples/demo.py +0 -0
  30. {anywidget_graph-0.4.0 → anywidget_graph-0.4.2}/examples/test_hierarchy.py +0 -0
  31. {anywidget_graph-0.4.0 → anywidget_graph-0.4.2}/examples/test_large_graph.py +0 -0
  32. {anywidget_graph-0.4.0 → anywidget_graph-0.4.2}/src/anywidget_graph/backends/__init__.py +0 -0
  33. {anywidget_graph-0.4.0 → anywidget_graph-0.4.2}/src/anywidget_graph/backends/arango.py +0 -0
  34. {anywidget_graph-0.4.0 → anywidget_graph-0.4.2}/src/anywidget_graph/backends/cosmosdb.py +0 -0
  35. {anywidget_graph-0.4.0 → anywidget_graph-0.4.2}/src/anywidget_graph/backends/grafeo.py +0 -0
  36. {anywidget_graph-0.4.0 → anywidget_graph-0.4.2}/src/anywidget_graph/backends/ladybug.py +0 -0
  37. {anywidget_graph-0.4.0 → anywidget_graph-0.4.2}/src/anywidget_graph/backends/neo4j.py +0 -0
  38. {anywidget_graph-0.4.0 → anywidget_graph-0.4.2}/src/anywidget_graph/converters/__init__.py +0 -0
  39. {anywidget_graph-0.4.0 → anywidget_graph-0.4.2}/src/anywidget_graph/converters/base.py +0 -0
  40. {anywidget_graph-0.4.0 → anywidget_graph-0.4.2}/src/anywidget_graph/converters/common.py +0 -0
  41. {anywidget_graph-0.4.0 → anywidget_graph-0.4.2}/src/anywidget_graph/converters/cypher.py +0 -0
  42. {anywidget_graph-0.4.0 → anywidget_graph-0.4.2}/src/anywidget_graph/converters/gql.py +0 -0
  43. {anywidget_graph-0.4.0 → anywidget_graph-0.4.2}/src/anywidget_graph/converters/graphql.py +0 -0
  44. {anywidget_graph-0.4.0 → anywidget_graph-0.4.2}/src/anywidget_graph/converters/gremlin.py +0 -0
  45. {anywidget_graph-0.4.0 → anywidget_graph-0.4.2}/src/anywidget_graph/converters/sparql.py +0 -0
  46. {anywidget_graph-0.4.0 → anywidget_graph-0.4.2}/src/anywidget_graph/demo.py +0 -0
  47. {anywidget_graph-0.4.0 → anywidget_graph-0.4.2}/src/anywidget_graph/py.typed +0 -0
  48. {anywidget_graph-0.4.0 → anywidget_graph-0.4.2}/src/anywidget_graph/ui/grafeo-embed.js +0 -0
  49. {anywidget_graph-0.4.0 → anywidget_graph-0.4.2}/src/anywidget_graph/ui/grafeo-result.js +0 -0
  50. {anywidget_graph-0.4.0 → anywidget_graph-0.4.2}/src/anywidget_graph/ui/grafeo.js +0 -0
  51. {anywidget_graph-0.4.0 → anywidget_graph-0.4.2}/src/anywidget_graph/ui/icons.js +0 -0
  52. {anywidget_graph-0.4.0 → anywidget_graph-0.4.2}/src/anywidget_graph/ui/neo4j.js +0 -0
  53. {anywidget_graph-0.4.0 → anywidget_graph-0.4.2}/src/anywidget_graph/ui/properties.js +0 -0
  54. {anywidget_graph-0.4.0 → anywidget_graph-0.4.2}/src/anywidget_graph/ui/results.js +0 -0
  55. {anywidget_graph-0.4.0 → anywidget_graph-0.4.2}/src/anywidget_graph/ui/settings.js +0 -0
  56. {anywidget_graph-0.4.0 → anywidget_graph-0.4.2}/src/anywidget_graph/ui/styles.css +0 -0
  57. {anywidget_graph-0.4.0 → anywidget_graph-0.4.2}/tests/__init__.py +0 -0
  58. {anywidget_graph-0.4.0 → anywidget_graph-0.4.2}/tests/conftest.py +0 -0
  59. {anywidget_graph-0.4.0 → anywidget_graph-0.4.2}/tests/js/grafeo-result.test.mjs +0 -0
  60. {anywidget_graph-0.4.0 → anywidget_graph-0.4.2}/tests/test_backends_grafeo.py +0 -0
  61. {anywidget_graph-0.4.0 → anywidget_graph-0.4.2}/tests/test_demo.py +0 -0
  62. {anywidget_graph-0.4.0 → anywidget_graph-0.4.2}/tests/test_grafeo_contract.py +0 -0
@@ -1,5 +1,60 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.2 2026-10-09
4
+
5
+ ### Features
6
+
7
+ All opt-in per lane: a lane without these options is laid out exactly as in 0.4.1.
8
+
9
+ - **Force layout for a lane**: `layout: "force"` lays the lane out with forces inside its band (repulsion, collision, edges as springs, a pull towards the height of its partners in the lanes to its left), so a dense graph spreads over its lane instead of shrinking into a core; nodes never leave their lane, a crowded lane draws its nodes smaller, and the layout is deterministic. Appended nodes settle from where they land while older nodes stay put
10
+ - **Rows in a lane**: `rows: {"field": ..., "order": [...]}` splits a lane into rows top to bottom by a node field (sized by node count, with a minimum; other values go to an extra row at the bottom), with each row's name (with room of its own, no node under it) and a faint separator
11
+ - **Lay a lane out again on a big batch**: `relayout: <share>` lays the lane out again when one batch removes or adds more than that share of its nodes (a re-sample); its nodes glide to their new places
12
+ - **Colours per type**: `type_colors` (`{"nodes": {"<type>": "#hex"}, "edges": {"<type>": "#hex"}}`) colours the items of a type and their legend swatches, so the legend matches the drawing; an item's own `color` still wins
13
+ - **Labels for a lane's largest nodes**: `labels: {"count": n}` shows the labels of a lane's n largest nodes at the default zoom, and no others in that lane (hover or select a node to see its label). A label may cover smaller nodes on a soft backing, never a node as large, a labelled node or another label; it goes on the left of its node at the lane's right edge (never over a row's name), and a long name is shortened with an ellipsis
14
+ - **Defaults for hosts without Python**: a setting an app's own model leaves out reads as the widget's default (as a fresh Python `Graph()` has it), so for example labels show without the host sending `show_labels`
15
+ - `_features` adds `force_layout`, `lane_rows`, `relayout`, `type_colors` and `lane_labels`
16
+
17
+ ### Documentation
18
+
19
+ - README: "Embedding in an app": the widget stays generic and the host decides looks, samples and lane layouts (all opt-in); the model contract for an app that mounts the front end without Python
20
+
21
+ ### Bug Fixes
22
+
23
+ - Fixed the zoom controls covering the caption of an action lane at the right edge on a narrow canvas: when the rightmost lane is an action lane, the framing leaves room for the controls
24
+ - Fixed the empty canvas mapping lanes with the y axis pointing down while sigma points it up (nothing visible depended on it before rows)
25
+
26
+ ## 0.4.1 2026-10-09
27
+
28
+ ### Features
29
+
30
+ - **Remove in a batch**: `Graph.remove(nodes, edges)` (or `remove` in the `append_batch` a host sets) takes nodes (with every edge touching them) and edges out of the drawing; they fade out and nothing is laid out again. One batch can remove and add (a re-sample): an item removed and sent again stays where it is, with its new properties
31
+ - **Totals from the host**: `totals` (`{"nodes": {"<type>": n}, "edges": {"<type>": n}}`) shows "shown / total" in the count badge and the schema panel, for a host that sends a sample of a larger graph; a type the sample lacks shows as 0
32
+ - **Edges into an action lane**: an edge whose end is an action lane's id draws as a curve into its button, like the cross-lane curves (draws in, can be removed, lights up with its node, hides with it)
33
+ - **Lane icons**: an action lane shows the host's `icon` (SVG markup, where `currentColor` takes the accent, or an image URL); `glyph` colours still draw stacked bars, and without either a neutral "open" icon shows (it used to be stacked bars, a model icon)
34
+ - `_features` adds `lane_icons`, `action_edges`, `remove` and `totals`
35
+
36
+ ### Improvements
37
+
38
+ - **Appended items stay**: `append()` also merges its items into `nodes` and `edges`, so `to_json()`, `to_html()` and a widget shown again include them; the widget sees the lists match what it draws and does not lay out again. Changing `lanes` or a style keeps appended items (it used to drop them); setting `nodes` or `edges` to other data still replaces everything drawn
39
+ - **The model holds what is drawn**: a host that sets `append_batch` itself (without `Graph.append`) gets the merged lists written back into `nodes` and `edges`, so the node count, the results table, the schema panel and the host see appended items too
40
+ - **Search across lanes**: a search also shows a match's partners in the other lanes, with the curves between them
41
+ - **Lane margins**: nodes keep a margin (6% per side) from their lane's edges, so none sits right under the lane title or on the border; laid-out, column and appended nodes alike
42
+ - **Lane colours from the widget**: the lane overlay (titles, buttons, curves) takes the widget's light or dark colours for a key the host theme leaves out (it fell back to fixed colours)
43
+ - **One redraw per change**: lanes, nodes, edges and style changes that arrive together are drawn once (a host that sets `lanes`, `nodes` and `edges` one after another got three layouts)
44
+ - **Dependencies**: the `dev` extra and group require `pytest>=9.1`, `ruff>=0.16` and `prek>=0.5`; the `pandas` extra requires `pandas>=3`, `cosmosdb` requires `gremlinpython>=3.8`
45
+
46
+ ### Bug Fixes
47
+
48
+ - Fixed `append()` without lanes moving the drawn graph: new nodes now go next to their neighbours inside the area already drawn, so the camera keeps its scale (they were placed on a fixed 1000-unit square, which could make sigma rescale)
49
+ - Fixed cross-lane curves still drawn to a node hidden by a filter or a search
50
+ - Fixed `theme` keeping old colours when a key is left out or the theme is cleared, and a `dark_mode` change resetting the label colour the theme sets
51
+ - Fixed a click on an action lane's glyph going unreported in a widget shown twice (or shown again): `lane_action.seq` now counts on from the model's value instead of from 1 in every view
52
+ - Fixed the Delete key dropping appended items it did not delete
53
+ - Fixed `pulse_nodes` set before the widget renders being ignored
54
+ - Fixed nodes appended (or redrawn) during an active search staying hidden even when they match it
55
+ - Fixed appended nodes and their labels landing on top of each other: ids that differ only at the end (`n1`, `n2`, ...) got nearly the same spot, and nodes next to the same neighbour could share one. Nodes of the same neighbours now stack as rows at its height, one label line apart (then in the next column), clear of the labels already drawn, and their labels show at the default zoom (sigma's label grid dropped all but two of a stack)
56
+ - Fixed lane titles sitting at the top edge of an empty canvas, and the lanes jumping when the first node arrived (for example at the start of a live append): an empty canvas is now framed with sigma's stage padding, like a canvas with nodes
57
+
3
58
  ## 0.4.0 2026-10-09
4
59
 
5
60
  ### Features
@@ -0,0 +1,423 @@
1
+ Metadata-Version: 2.5
2
+ Name: anywidget-graph
3
+ Version: 0.4.2
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" <dev@grafeo.dev>
8
+ License-Expression: Apache-2.0
9
+ License-File: LICENSE
10
+ Keywords: anywidget,cypher,graph,jupyter,marimo,neo4j,visualization
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Framework :: Jupyter
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: License :: OSI Approved :: Apache Software License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Topic :: Scientific/Engineering :: Visualization
21
+ Requires-Python: >=3.12
22
+ Requires-Dist: anywidget>=0.9.21
23
+ Requires-Dist: marimo>=0.19.9
24
+ Provides-Extra: cosmosdb
25
+ Requires-Dist: gremlinpython>=3.8; extra == 'cosmosdb'
26
+ Provides-Extra: demo
27
+ Provides-Extra: dev
28
+ Requires-Dist: marimo>=0.20; extra == 'dev'
29
+ Requires-Dist: prek>=0.5; extra == 'dev'
30
+ Requires-Dist: pytest>=9.1; extra == 'dev'
31
+ Requires-Dist: ruff>=0.16; extra == 'dev'
32
+ Requires-Dist: ty>=0.0; extra == 'dev'
33
+ Provides-Extra: embed
34
+ Provides-Extra: grafeo
35
+ Requires-Dist: grafeo<0.7,>=0.5.44; extra == 'grafeo'
36
+ Provides-Extra: networkx
37
+ Requires-Dist: networkx>=3; extra == 'networkx'
38
+ Provides-Extra: pandas
39
+ Requires-Dist: pandas>=3; extra == 'pandas'
40
+ Description-Content-Type: text/markdown
41
+
42
+ # anywidget-graph
43
+
44
+ Interactive graph visualization for Python notebooks.
45
+
46
+ Works with Marimo, Jupyter, VS Code, Colab, anywhere [anywidget](https://anywidget.dev/) runs.
47
+
48
+ ![anywidget-graph demo](assets/demo.png)
49
+
50
+ ## Features
51
+
52
+ - **Universal**: One widget, every notebook environment
53
+ - **Backend-agnostic**: Grafeo, Neo4j, NetworkX, pandas, or raw dicts
54
+ - **Interactive**: Pan, zoom, click, drag, pin, expand neighbors, box select
55
+ - **Customizable**: Colors, sizes, layouts, dark mode
56
+ - **Exportable**: HTML, JSON
57
+
58
+ ## Installation
59
+
60
+ ```bash
61
+ uv add anywidget-graph
62
+ ```
63
+
64
+ Optional extras:
65
+
66
+ ```bash
67
+ uv add "anywidget-graph[networkx]" # NetworkX support
68
+ uv add "anywidget-graph[pandas]" # pandas support
69
+ uv add "anywidget-graph[grafeo]" # Grafeo backend
70
+ uv add "anywidget-graph[cosmosdb]" # CosmosDB / Gremlin support
71
+ ```
72
+
73
+ ## Quick Start
74
+
75
+ ```python
76
+ from anywidget_graph import Graph
77
+
78
+ graph = Graph.from_dict(
79
+ {
80
+ "nodes": [
81
+ {"id": "alice", "label": "Alice", "group": "person"},
82
+ {"id": "bob", "label": "Bob", "group": "person"},
83
+ {"id": "paper", "label": "Graph Theory", "group": "document"},
84
+ ],
85
+ "edges": [
86
+ {"source": "alice", "target": "bob", "label": "knows"},
87
+ {"source": "alice", "target": "paper", "label": "authored"},
88
+ ],
89
+ }
90
+ )
91
+
92
+ graph
93
+ ```
94
+
95
+ ## Data Sources
96
+
97
+ ### Dictionary
98
+
99
+ ```python
100
+ graph = Graph.from_dict({"nodes": [{"id": "a"}, {"id": "b"}], "edges": [{"source": "a", "target": "b"}]})
101
+ ```
102
+
103
+ ### Direct initialization
104
+
105
+ ```python
106
+ graph = Graph(
107
+ nodes=[{"id": "a", "label": "Alice"}, {"id": "b", "label": "Bob"}],
108
+ edges=[{"source": "a", "target": "b", "label": "KNOWS"}],
109
+ )
110
+ ```
111
+
112
+ ### Cypher results (Neo4j)
113
+
114
+ ```python
115
+ from neo4j import GraphDatabase
116
+
117
+ driver = GraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "password"))
118
+
119
+ with driver.session() as session:
120
+ result = session.run("MATCH (a)-[r]->(b) RETURN a, r, b LIMIT 100")
121
+ graph = Graph.from_cypher(result)
122
+ ```
123
+
124
+ ### GQL results
125
+
126
+ ```python
127
+ graph = Graph.from_gql(result)
128
+ ```
129
+
130
+ ### SPARQL results
131
+
132
+ ```python
133
+ from rdflib import Graph as RDFGraph
134
+
135
+ g = RDFGraph()
136
+ g.parse("data.ttl")
137
+ result = g.query("SELECT ?s ?p ?o WHERE { ?s ?p ?o }")
138
+ graph = Graph.from_sparql(result)
139
+ ```
140
+
141
+ ### Gremlin results (CosmosDB, TinkerPop)
142
+
143
+ ```python
144
+ graph = Graph.from_gremlin(result)
145
+ ```
146
+
147
+ ### GraphQL results
148
+
149
+ ```python
150
+ graph = Graph.from_graphql(
151
+ response.json(),
152
+ nodes_path="data.characters.results",
153
+ id_field="id",
154
+ label_field="name",
155
+ )
156
+ ```
157
+
158
+ ### NetworkX
159
+
160
+ ```python
161
+ import networkx as nx
162
+
163
+ G = nx.karate_club_graph()
164
+ graph = Graph.from_networkx(G)
165
+ ```
166
+
167
+ ### pandas DataFrames
168
+
169
+ ```python
170
+ import pandas as pd
171
+
172
+ nodes_df = pd.DataFrame({"id": ["alice", "bob"], "group": ["person", "person"]})
173
+ edges_df = pd.DataFrame({"source": ["alice"], "target": ["bob"], "weight": [1.0]})
174
+
175
+ graph = Graph.from_dataframe(nodes_df, edges_df)
176
+ ```
177
+
178
+ ## Interactivity
179
+
180
+ ### Events
181
+
182
+ ```python
183
+ graph = Graph.from_dict(data)
184
+
185
+
186
+ @graph.on_node_click
187
+ def handle_node(node_id, node_data):
188
+ print(f"Clicked: {node_id}")
189
+
190
+
191
+ @graph.on_edge_click
192
+ def handle_edge(edge_data):
193
+ print(f"Edge: {edge_data['label']}")
194
+
195
+
196
+ @graph.on_selection
197
+ def handle_selection(node_ids):
198
+ print(f"Selected: {node_ids}")
199
+ ```
200
+
201
+ ### Selection
202
+
203
+ ```python
204
+ graph.selected_nodes # Current selection (list of IDs)
205
+ graph.selection_mode = "box" # Switch to box-select mode
206
+ ```
207
+
208
+ ### Node expansion
209
+
210
+ ```python
211
+ graph.expand_node("alice") # Fetch and merge neighbors (requires backend)
212
+ ```
213
+
214
+ ### Node pinning
215
+
216
+ ```python
217
+ graph.pin_nodes(["alice", "bob"]) # Pin at current positions
218
+ graph.unpin_nodes(["alice"]) # Release back to layout
219
+ graph.toggle_pin("bob") # Toggle pin state
220
+ graph.unpin_all() # Unpin everything
221
+ ```
222
+
223
+ ### Clear
224
+
225
+ ```python
226
+ graph.clear() # Remove all nodes, edges, pins, and selection
227
+ ```
228
+
229
+ ### Linked lanes and live append
230
+
231
+ 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:
232
+
233
+ ```python
234
+ graph = Graph(
235
+ lanes=[{"id": "source", "title": "Source graph"}, {"id": "target", "title": "Target graph"}],
236
+ nodes=[{"id": "a", "lane": "source"}, {"id": "b", "lane": "source"}, {"id": "x", "lane": "target"}],
237
+ edges=[{"source": "a", "target": "b"}, {"source": "x", "target": "a", "cross": True}],
238
+ )
239
+ graph.append(nodes=[{"id": "y", "lane": "target"}], edges=[{"source": "y", "target": "b"}])
240
+ graph.remove(nodes=["x"]) # fades out, nothing else moves
241
+ graph.pulse_nodes = ["b"] # a soft pulse on these nodes; [] stops it
242
+ graph.theme = {
243
+ "background": "#0d1416",
244
+ "panel": "#121b1e",
245
+ "text": "#dbe7e5",
246
+ "muted": "#8ea3a3",
247
+ "border": "#1e2b2f",
248
+ "accent": "#5bb8a9",
249
+ }
250
+ ```
251
+
252
+ - `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; a search also shows a match's partners in the other lanes. Without `lanes` the widget behaves as before.
253
+ - `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, where nodes of the same neighbours stack as rows one label line apart with their labels shown (or next to its neighbours in its lane; without lanes, next to its neighbours inside the area already drawn). The items are also merged into `nodes` and `edges`, so `to_json()`, `to_html()` and a widget shown again include them (a host that sets `append_batch` itself gets the merged lists written back by the widget). Changing `lanes` or a style keeps appended items; setting `nodes` or `edges` to other data replaces everything drawn. 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.
254
+ - `remove(nodes, edges)`: takes nodes (with every edge touching them) and edges out without a re-layout; they fade out and leave `nodes` and `edges`. An edge is `{"source", "target"}` (every edge between those ends) or with a `"label"` (that edge only). A host that sets `append_batch` itself puts them under `remove`: `{"seq": n, "nodes": [...], "edges": [...], "remove": {"nodes": [ids], "edges": [...]}}`; removals come first, so one batch can re-sample (an item removed and sent again stays where it is).
255
+ - 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 button, with the host's `icon` (SVG markup, where `currentColor` takes the accent colour, or an image URL), or `glyph` (a list of colours drawn as stacked bars), else a neutral "open" icon; `caption` is text under it. An edge whose end is an action lane's id (and no node) is drawn as a curve into its button, like the cross-lane curves. The camera frames all lanes, and the lanes stretch to the canvas's shape.
256
+ - Lane layout options (all off by default; a lane without them is laid out as before):
257
+ - `layout: "force"`: a force layout inside the lane: nodes repel and do not overlap, edges inside the lane are springs, and a node is pulled towards the height of its partners in the lanes to its left. Nodes never leave their lane. A crowded lane draws its nodes smaller so the layout has room. Appended nodes settle from where they land while the older nodes stay put. Deterministic: the same data gives the same picture.
258
+ - `rows: {"field": "layer", "order": ["business", "application", "technology"]}`: splits the lane into rows, top to bottom, by a node field; a node with another value (or none) goes to an extra row at the bottom. Row heights follow the node counts (with a minimum), each row shows its name and a faint separator, and nodes stay inside their row. Implies `layout: "force"`.
259
+ - `labels: {"count": 12}`: at the default zoom, shows the labels of the lane's 12 largest nodes (by size, then degree), and no others (hover or select a node to see its label; zoomed in or out, sigma's label grid decides). A label may cover smaller nodes, on a soft backing so it stays readable, but never a node as large, a labelled node or another label; one that would cross the lane's right edge goes on the left of its node, and a long name is shortened with an ellipsis (hover shows it whole).
260
+ - `relayout: 0.33`: when one batch removes or adds more than this share of the lane's nodes (a re-sample), the lane is laid out again as a whole and its nodes glide to their new places; smaller batches keep older nodes where they are.
261
+ - `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). `seq` counts on from the value the model holds, so every click is a change, also in a widget shown twice.
262
+ - `pulse_nodes`: ids that pulse softly.
263
+ - `type_colors`: colours per type, `{"nodes": {"<type>": "#hex"}, "edges": {"<type>": "#hex"}}` (types as the schema panel groups them). They colour the items of that type and the legend swatches; an item's own `color` still wins, and other types keep the palette.
264
+ - `totals`: for a host that sends a sample of a larger graph, `{"nodes": {"<type>": n}, "edges": {"<type>": n}}` (types as the schema panel groups them: a node's first label, an edge's type). The count badge and the schema panel show "shown / total"; a type the sample lacks shows as 0, a type without a total shows its count (and counts as shown in full).
265
+ - `theme`: host colours (`background`, `panel`, `text`, `muted`, `border`, `accent`) that replace the widget's light and dark defaults; a key left out (or `theme = {}`) brings the default back.
266
+ - `_features`: the widget sets the capabilities it supports when it renders, so a host can detect an older build.
267
+
268
+ ## Styling
269
+
270
+ ### Property-based coloring
271
+
272
+ ```python
273
+ graph = Graph.from_dict(
274
+ data,
275
+ color_field="group", # Color nodes by field
276
+ color_scale="viridis", # Scale: viridis, plasma, inferno, magma, cividis, turbo
277
+ size_field="score", # Size nodes by field
278
+ size_range=[5, 30], # Min/max node size
279
+ )
280
+ ```
281
+
282
+ ### Edge styling
283
+
284
+ ```python
285
+ graph.edge_color_field = "type"
286
+ graph.edge_color_scale = "plasma"
287
+ graph.edge_size_field = "weight"
288
+ graph.edge_size_range = [1, 8]
289
+ ```
290
+
291
+ ### Layouts
292
+
293
+ ```python
294
+ Graph.from_dict(data, layout="force") # ForceAtlas2 (default)
295
+ Graph.from_dict(data, layout="circular")
296
+ Graph.from_dict(data, layout="random")
297
+ ```
298
+
299
+ ## Options
300
+
301
+ ```python
302
+ graph = Graph(
303
+ nodes=nodes,
304
+ edges=edges,
305
+ width=800, # Widget width (px)
306
+ height=600, # Widget height (px)
307
+ fill=False, # Take the host's size and follow resizes (ignores width/height)
308
+ background="#fafafa", # Background color
309
+ show_labels=True, # Node labels
310
+ show_edge_labels=False, # Edge labels
311
+ show_toolbar=True, # Toolbar visibility
312
+ show_settings=True, # Settings panel
313
+ show_query_input=True, # Query input box
314
+ dark_mode=True, # Dark theme
315
+ show_tooltip=True, # Hover tooltips
316
+ tooltip_fields=["label", "id"],
317
+ max_nodes=300, # Limit for node expansion
318
+ )
319
+ ```
320
+
321
+ ## Database Backends
322
+
323
+ ### Grafeo (default)
324
+
325
+ Grafeo runs in one of three modes, picked with `grafeo_connection_mode` or in the settings panel.
326
+
327
+ **Embedded** (Python, the default): queries run in the notebook kernel. Requires the `grafeo` extra (`grafeo>=0.5.44,<0.7`).
328
+
329
+ ```python
330
+ import grafeo
331
+
332
+ db = grafeo.GrafeoDB()
333
+ graph = Graph(database_backend="grafeo", grafeo_db=db, query_language="gql")
334
+ ```
335
+
336
+ **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`.
337
+
338
+ ```python
339
+ graph = Graph(database_backend="grafeo", grafeo_connection_mode="server", grafeo_server_url="http://localhost:7474")
340
+ ```
341
+
342
+ **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.
343
+
344
+ ```python
345
+ graph = Graph(database_backend="grafeo", grafeo_connection_mode="wasm", query_language="gql")
346
+ ```
347
+
348
+ 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.
349
+
350
+ Try it without any setup:
351
+
352
+ ```python
353
+ from anywidget_graph.demo import demo_graph
354
+
355
+ demo_graph() # WASM mode with a small movie graph loaded
356
+ ```
357
+
358
+ ### Neo4j (browser-side)
359
+
360
+ ```python
361
+ graph = Graph(
362
+ database_backend="neo4j",
363
+ connection_uri="neo4j+s://demo.neo4jlabs.com",
364
+ connection_username="neo4j",
365
+ connection_password="password",
366
+ )
367
+ ```
368
+
369
+ ### Generic backend
370
+
371
+ ```python
372
+ graph = Graph(backend=my_backend) # Any object implementing DatabaseBackend protocol
373
+ ```
374
+
375
+ ## Embedding in an app
376
+
377
+ The widget knows graphs, not your domain. The host (a notebook, or an app that mounts the widget's front end) decides how its data looks and behaves, and the widget's own defaults stay neutral:
378
+
379
+ - **Looks**: colours per type (`type_colors`), the host's colours (`theme`), action lane icons (`icon`, `glyph`), lane titles and captions.
380
+ - **What to show**: when a graph is too large to draw whole, the host picks the sample and sends it, with the totals behind it (`totals`); the widget never makes counts up. `append()` and `remove()` keep the drawing up to date without a re-layout.
381
+ - **Layout per lane**: `width`, `arrange`, `layout`, `rows`, `relayout` and `labels` are lane options the host sets.
382
+
383
+ New behaviour is opt-in and off by default: an update does not change a view that does not ask for it. `_features` lists what a build supports, so a host can tell an older build apart and fall back.
384
+
385
+ ### Without Python
386
+
387
+ An app can mount the bundled front end (`anywidget_graph.ui.get_esm()` and `get_css()`) with any model object that has `get`, `set`, `on`, `off`, `save_changes` and `send`, as anywidget's model does.
388
+
389
+ - The host sets `nodes`, `edges`, `lanes`, `append_batch`, `pulse_nodes`, `theme`, `type_colors`, `totals`, `dark_mode`, and the size (`width` and `height`, or `fill`).
390
+ - The widget sets `selected_node`, `selected_nodes`, `selected_edge`, `hovered_node`, `lane_action` and `_features`. After an `append_batch` it writes the merged `nodes` and `edges` back, unless they already hold the batch (as `Graph.append()` sends them).
391
+ - A setting the host leaves out reads as the widget's default, the same as a fresh Python `Graph()` has it.
392
+ - The widget sends no query when it mounts: the first view is the host's.
393
+
394
+ ## Export
395
+
396
+ ```python
397
+ graph.to_json() # JSON string with nodes and edges
398
+ graph.to_html() # Self-contained HTML string
399
+ graph.to_html(title="My Graph") # Custom title
400
+ graph.save_html("graph.html") # Write HTML to file
401
+ ```
402
+
403
+ ## Environment Support
404
+
405
+ | Environment | Supported |
406
+ |-------------|-----------|
407
+ | Marimo | Yes |
408
+ | JupyterLab | Yes |
409
+ | Jupyter Notebook | Yes |
410
+ | VS Code | Yes |
411
+ | Google Colab | Yes |
412
+ | Databricks | Yes |
413
+
414
+ ## Related
415
+
416
+ - [anywidget](https://anywidget.dev/), custom Jupyter widgets made easy
417
+ - [Grafeo](https://github.com/GrafeoDB/grafeo), embeddable graph database
418
+ - [grafeo-web](https://github.com/GrafeoDB/grafeo-web), Grafeo in the browser
419
+ - [Playground](https://grafeo.ai), interactive browser playground for Grafeo
420
+
421
+ ## License
422
+
423
+ Apache-2.0