reflex-gravityui-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,178 @@
1
+ // Local React bridge for @gravity-ui/graph.
2
+ //
3
+ // @gravity-ui/graph is hook + imperative driven: you create a `Graph` instance
4
+ // with the `useGraph` hook, push entities through `setEntities`, and start the
5
+ // render loop once the canvas is ATTACHED. Reflex components cannot call React
6
+ // hooks directly, so this thin bridge encapsulates the whole lifecycle and
7
+ // exposes a flat, declarative prop surface that the Python wrapper drives.
8
+ //
9
+ // Everything below is plain, JSON-serializable data (blocks, connections,
10
+ // settings, colors, constants) plus a handful of event callbacks, which is
11
+ // exactly what Reflex can pass across the Python <-> JS boundary.
12
+ //
13
+ // Requires the npm dependency `@gravity-ui/graph` (declared as a
14
+ // `lib_dependency` on the Python side so Reflex installs it).
15
+ import React, { useEffect, useMemo } from "react";
16
+ import { GraphState } from "@gravity-ui/graph";
17
+ import {
18
+ GraphCanvas,
19
+ GraphBlock,
20
+ useGraph,
21
+ useGraphEvents,
22
+ } from "@gravity-ui/graph/react";
23
+
24
+ // Pull the `.id` off a list of canvas component instances (defensively, since
25
+ // the exact shape of an event payload's component objects is internal).
26
+ function idsOf(list) {
27
+ if (!Array.isArray(list)) return [];
28
+ return list
29
+ .map((item) => {
30
+ if (item == null) return null;
31
+ if (typeof item === "object") {
32
+ if ("id" in item) return item.id;
33
+ if (item.state && "id" in item.state) return item.state.id;
34
+ }
35
+ return item;
36
+ })
37
+ .filter((id) => id !== null && id !== undefined);
38
+ }
39
+
40
+ export function GravityGraph(props) {
41
+ const {
42
+ blocks = [],
43
+ connections = [],
44
+ settings = {},
45
+ colors = null,
46
+ constants = null,
47
+ configName = "reflex-gravityui-graph",
48
+ autoStart = true,
49
+ zoomOnStart = true,
50
+ zoomTarget = "center",
51
+ zoomPadding = 100,
52
+ renderHtmlBlocks = false,
53
+ onBlockSelectionChange = null,
54
+ onConnectionCreated = null,
55
+ onConnectionSelectionChange = null,
56
+ onCameraChange = null,
57
+ onBlockDragEnd = null,
58
+ onStateChange = null,
59
+ style = null,
60
+ className = null,
61
+ } = props;
62
+
63
+ // Assemble the optional view configuration (colors + constants) only when at
64
+ // least one of them is supplied, so we never clobber library defaults.
65
+ const viewConfiguration = useMemo(() => {
66
+ const view = {};
67
+ if (colors) view.colors = colors;
68
+ if (constants) view.constants = constants;
69
+ return view;
70
+ }, [JSON.stringify(colors), JSON.stringify(constants)]);
71
+
72
+ const { graph, setEntities, start } = useGraph({
73
+ name: configName,
74
+ settings,
75
+ viewConfiguration,
76
+ });
77
+
78
+ // Push the entity set whenever blocks/connections change. We stringify for
79
+ // the dependency array so structurally-equal updates don't thrash the canvas.
80
+ const blocksKey = JSON.stringify(blocks);
81
+ const connectionsKey = JSON.stringify(connections);
82
+ useEffect(() => {
83
+ if (!graph) return;
84
+ setEntities({ blocks, connections });
85
+ // eslint-disable-next-line react-hooks/exhaustive-deps
86
+ }, [graph, setEntities, blocksKey, connectionsKey]);
87
+
88
+ // Live-apply settings changes after the first mount.
89
+ const settingsKey = JSON.stringify(settings);
90
+ useEffect(() => {
91
+ if (graph && graph.updateSettings) graph.updateSettings(settings);
92
+ // eslint-disable-next-line react-hooks/exhaustive-deps
93
+ }, [graph, settingsKey]);
94
+
95
+ // Live-apply color / constant changes.
96
+ useEffect(() => {
97
+ if (!graph) return;
98
+ if (colors && graph.setColors) graph.setColors(colors);
99
+ if (constants && graph.setConstants) graph.setConstants(constants);
100
+ // eslint-disable-next-line react-hooks/exhaustive-deps
101
+ }, [graph, JSON.stringify(colors), JSON.stringify(constants)]);
102
+
103
+ // Subscribe to the graph events the Python wrapper cares about. Each payload
104
+ // is reduced to plain, serializable data before crossing back to Reflex.
105
+ useGraphEvents(graph, {
106
+ "blocks-selection-change": (detail) => {
107
+ if (!onBlockSelectionChange) return;
108
+ const changes = (detail && detail.changes) || {};
109
+ onBlockSelectionChange({
110
+ added: idsOf(changes.add),
111
+ removed: idsOf(changes.removed),
112
+ });
113
+ },
114
+ "connection-created": (detail) => {
115
+ if (!onConnectionCreated) return;
116
+ onConnectionCreated({
117
+ sourceBlockId: detail?.sourceBlockId ?? null,
118
+ targetBlockId: detail?.targetBlockId ?? null,
119
+ sourceAnchorId: detail?.sourceAnchorId ?? null,
120
+ targetAnchorId: detail?.targetAnchorId ?? null,
121
+ });
122
+ },
123
+ "connection-selection-change": (detail) => {
124
+ if (!onConnectionSelectionChange) return;
125
+ const changes = (detail && detail.changes) || {};
126
+ onConnectionSelectionChange({
127
+ added: idsOf(changes.add),
128
+ removed: idsOf(changes.removed),
129
+ });
130
+ },
131
+ "camera-change": (detail) => {
132
+ if (!onCameraChange) return;
133
+ onCameraChange({
134
+ x: detail?.x ?? null,
135
+ y: detail?.y ?? null,
136
+ scale: detail?.scale ?? null,
137
+ });
138
+ },
139
+ "block-drag-end": (detail) => {
140
+ if (!onBlockDragEnd) return;
141
+ onBlockDragEnd({
142
+ blockId: detail?.blockId ?? detail?.block?.id ?? null,
143
+ x: detail?.x ?? null,
144
+ y: detail?.y ?? null,
145
+ });
146
+ },
147
+ });
148
+
149
+ // When requested, render rich HTML/React blocks at detailed zoom levels. The
150
+ // default (canvas-only) path is the most robust and needs no registration.
151
+ const renderBlock = renderHtmlBlocks
152
+ ? (g, block) => (
153
+ <GraphBlock graph={g} block={block}>
154
+ {block.name ?? block.id}
155
+ </GraphBlock>
156
+ )
157
+ : undefined;
158
+
159
+ const wrapperStyle = style || { width: "100%", height: "600px" };
160
+
161
+ return (
162
+ <GraphCanvas
163
+ className={className}
164
+ style={wrapperStyle}
165
+ graph={graph}
166
+ renderBlock={renderBlock}
167
+ onStateChanged={({ state }) => {
168
+ if (onStateChange) onStateChange({ state });
169
+ if (state === GraphState.ATTACHED) {
170
+ if (autoStart) start();
171
+ if (zoomOnStart && graph) {
172
+ graph.zoomTo(zoomTarget, { padding: zoomPadding });
173
+ }
174
+ }
175
+ }}
176
+ />
177
+ );
178
+ }
@@ -0,0 +1,287 @@
1
+ """Ergonomic helpers that reproduce the upstream @gravity-ui/graph examples.
2
+
3
+ The library's Storybook ships many stories: hello-world blocks, anchored
4
+ connections, bezier vs straight edges, colored/dashed connections, and large
5
+ "layered" graphs with hundreds to thousands of nodes. These helpers build the
6
+ equivalent ``blocks``/``connections``/``settings``/``colors`` payloads from a
7
+ plain-Python API so the Reflex demo (and consumers) never hand-write the data.
8
+
9
+ All functions return JSON-serializable structures ready to pass straight to
10
+ ``graph_canvas(...)``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import math
16
+ import random
17
+ from typing import Any
18
+
19
+ from .domain import (
20
+ DEFAULT_BLOCK_IS,
21
+ Anchor,
22
+ Block,
23
+ Connection,
24
+ EAnchorType,
25
+ GraphData,
26
+ )
27
+
28
+ # --------------------------------------------------------------------------- #
29
+ # Settings presets (mirror the Storybook argTypes / story configs)
30
+ # --------------------------------------------------------------------------- #
31
+
32
+
33
+ # Valid @gravity-ui/graph ``canDrag`` (ECanDrag) values: which blocks the user
34
+ # can move with the mouse. The library default is ``"none"`` (blocks immovable).
35
+ _CAN_DRAG_VALUES = {"all", "onlySelected", "none"}
36
+
37
+
38
+ def _normalize_can_drag(value: bool | str) -> str:
39
+ """Map an ergonomic ``can_drag_blocks`` flag to an ECanDrag string."""
40
+ if isinstance(value, bool):
41
+ return "all" if value else "none"
42
+ if value not in _CAN_DRAG_VALUES:
43
+ raise ValueError(
44
+ f"can_drag_blocks must be a bool or one of {sorted(_CAN_DRAG_VALUES)}, "
45
+ f"got {value!r}"
46
+ )
47
+ return value
48
+
49
+
50
+ def default_settings(
51
+ *,
52
+ can_drag_camera: bool = True,
53
+ can_zoom_camera: bool = True,
54
+ can_drag_blocks: bool | str = True,
55
+ can_create_new_connections: bool = False,
56
+ use_bezier_connections: bool = True,
57
+ bezier_direction: str = "horizontal",
58
+ show_connection_arrows: bool = True,
59
+ show_connection_labels: bool = False,
60
+ use_blocks_anchors: bool = True,
61
+ scale_font_size: float = 1.0,
62
+ ) -> dict[str, Any]:
63
+ """Build a ``settings`` dict from the most common Storybook controls.
64
+
65
+ ``can_drag_blocks`` controls whether nodes can be moved with the mouse
66
+ (``canDrag``). The upstream default is ``"none"`` (immovable), so this
67
+ defaults to ``True`` -> ``"all"`` to keep the component interactive. Pass
68
+ ``False`` for ``"none"`` or ``"onlySelected"`` to restrict it.
69
+ """
70
+ return {
71
+ "canDragCamera": can_drag_camera,
72
+ "canZoomCamera": can_zoom_camera,
73
+ "canDrag": _normalize_can_drag(can_drag_blocks),
74
+ "canCreateNewConnections": can_create_new_connections,
75
+ "useBezierConnections": use_bezier_connections,
76
+ "bezierConnectionDirection": bezier_direction,
77
+ "showConnectionArrows": show_connection_arrows,
78
+ "showConnectionLabels": show_connection_labels,
79
+ "useBlocksAnchors": use_blocks_anchors,
80
+ "scaleFontSize": scale_font_size,
81
+ }
82
+
83
+
84
+ # --------------------------------------------------------------------------- #
85
+ # Color themes (mirror the "Theme" / "updateGraphColors" stories)
86
+ # --------------------------------------------------------------------------- #
87
+
88
+ LIGHT_THEME: dict[str, Any] = {
89
+ "canvas": {"layerBackground": "#f9f9f9", "belowLayerBackground": "#eaeaea",
90
+ "dots": "#d0d2ce", "border": "#EAEAEAFF"},
91
+ "block": {"background": "#e0e0e0", "border": "#dfdfdf", "text": "#272727",
92
+ "selectedBorder": "#FFCC00"},
93
+ "anchor": {"background": "#4a4a4a", "selectedBorder": "#FFCC00"},
94
+ "connection": {"background": "#272727", "selectedBackground": "#ecc113"},
95
+ "selection": {"background": "rgba(0,0,0,0.051)", "border": "#ecc113"},
96
+ }
97
+
98
+ DARK_THEME: dict[str, Any] = {
99
+ "canvas": {"layerBackground": "#1f1f1f", "belowLayerBackground": "#161616",
100
+ "dots": "#3a3a3a", "border": "#2a2a2aFF"},
101
+ "block": {"background": "#2c2c2c", "border": "#3a3a3a", "text": "#ffffff",
102
+ "selectedBorder": "#FFCC00"},
103
+ "anchor": {"background": "#c0c0c0", "selectedBorder": "#FFCC00"},
104
+ "connection": {"background": "#d0d0d0", "selectedBackground": "#ecc113"},
105
+ "selection": {"background": "rgba(255,255,255,0.08)", "border": "#ecc113"},
106
+ }
107
+
108
+ THEMES: dict[str, dict[str, Any]] = {"light": LIGHT_THEME, "dark": DARK_THEME}
109
+
110
+
111
+ # --------------------------------------------------------------------------- #
112
+ # Small, hand-built example graphs (the "Main/GraphEditor" basic stories)
113
+ # --------------------------------------------------------------------------- #
114
+
115
+
116
+ def one_block() -> GraphData:
117
+ """The hello-world story: a single block."""
118
+ return GraphData(
119
+ blocks=[Block(id="block_1", x=0, y=0, width=160, height=160,
120
+ name="Block #1")],
121
+ connections=[],
122
+ )
123
+
124
+
125
+ def two_connected_blocks(*, bezier: bool = True) -> GraphData:
126
+ """Two anchored blocks joined by one connection (straight or bezier)."""
127
+ a = Block(
128
+ id="action_1", x=-200, y=-120, width=160, height=160, name="Block #1",
129
+ anchors=[Anchor(id="out1", block_id="action_1", type=EAnchorType.OUT)],
130
+ )
131
+ b = Block(
132
+ id="action_2", x=220, y=140, width=160, height=160, name="Block #2",
133
+ anchors=[Anchor(id="in1", block_id="action_2", type=EAnchorType.IN)],
134
+ )
135
+ edge = Connection(
136
+ source_block_id="action_1", source_anchor_id="out1",
137
+ target_block_id="action_2", target_anchor_id="in1",
138
+ )
139
+ return GraphData(blocks=[a, b], connections=[edge])
140
+
141
+
142
+ def colored_connections() -> GraphData:
143
+ """Mirrors the "ColoredConnections" story: per-edge colored/dashed styles."""
144
+ palette = ["#e74c3c", "#27ae60", "#2980b9", "#8e44ad"]
145
+ blocks: list[Block] = []
146
+ connections: list[Connection] = []
147
+ hub = Block(id="hub", x=0, y=0, width=160, height=120, name="Hub",
148
+ anchors=[Anchor(id="hub_out", block_id="hub",
149
+ type=EAnchorType.OUT)])
150
+ blocks.append(hub)
151
+ for i, color in enumerate(palette):
152
+ angle = (i / len(palette)) * 2 * math.pi
153
+ leaf_id = f"leaf_{i}"
154
+ blocks.append(
155
+ Block(
156
+ id=leaf_id, x=int(360 * math.cos(angle)),
157
+ y=int(260 * math.sin(angle)), width=140, height=110,
158
+ name=f"Leaf {i + 1}",
159
+ anchors=[Anchor(id=f"{leaf_id}_in", block_id=leaf_id,
160
+ type=EAnchorType.IN)],
161
+ )
162
+ )
163
+ connections.append(
164
+ Connection(
165
+ source_block_id="hub", source_anchor_id="hub_out",
166
+ target_block_id=leaf_id, target_anchor_id=f"{leaf_id}_in",
167
+ label=f"edge {i + 1}", dashed=(i % 2 == 1),
168
+ styles={"background": color, "selectedBackground": color},
169
+ )
170
+ )
171
+ return GraphData(blocks=blocks, connections=connections)
172
+
173
+
174
+ def block_with_anchors(n_in: int = 2, n_out: int = 2) -> GraphData:
175
+ """Mirrors the "WithAnchors" story: a block exposing several IN/OUT anchors."""
176
+ anchors = [
177
+ Anchor(id=f"in_{i}", block_id="anchored", type=EAnchorType.IN, index=i)
178
+ for i in range(n_in)
179
+ ] + [
180
+ Anchor(id=f"out_{i}", block_id="anchored", type=EAnchorType.OUT, index=i)
181
+ for i in range(n_out)
182
+ ]
183
+ return GraphData(
184
+ blocks=[Block(id="anchored", x=0, y=0, width=200, height=200,
185
+ name="Anchored block", anchors=anchors)],
186
+ connections=[],
187
+ )
188
+
189
+
190
+ # --------------------------------------------------------------------------- #
191
+ # Large "layered" graph generator (Hundred/Thousand/FiveThousand stories)
192
+ # --------------------------------------------------------------------------- #
193
+
194
+
195
+ def generate_layered_blocks(
196
+ *,
197
+ layers_count: int = 10,
198
+ connections_per_layer: int = 20,
199
+ dashed: bool = False,
200
+ block_is: str = DEFAULT_BLOCK_IS,
201
+ seed: int | None = 42,
202
+ h_gap: int = 360,
203
+ v_gap: int = 220,
204
+ ) -> GraphData:
205
+ """Reproduce the upstream ``generatePrettyBlocks`` layered graphs.
206
+
207
+ Creates ``layers_count`` columns of nodes wired left-to-right, where each
208
+ layer holds roughly ``connections_per_layer`` nodes. Use it for the
209
+ "HundredBlocks" (10x100), "ThousandBlocks" (25x200) and "FiveThousandsBlocks"
210
+ (40x300) demos. A fixed ``seed`` keeps the layout reproducible.
211
+ """
212
+ if layers_count <= 0 or connections_per_layer <= 0:
213
+ raise ValueError("layers_count and connections_per_layer must be > 0")
214
+ rng = random.Random(seed)
215
+ nodes_per_layer = max(1, connections_per_layer)
216
+
217
+ blocks: list[Block] = []
218
+ layer_ids: list[list[str]] = []
219
+ for layer in range(layers_count):
220
+ ids: list[str] = []
221
+ for n in range(nodes_per_layer):
222
+ bid = f"l{layer}_n{n}"
223
+ blocks.append(
224
+ Block(
225
+ id=bid, is_=block_is,
226
+ x=layer * h_gap,
227
+ y=int((n - nodes_per_layer / 2) * v_gap),
228
+ width=150, height=90, name=bid,
229
+ anchors=[
230
+ Anchor(id=f"{bid}_in", block_id=bid, type=EAnchorType.IN),
231
+ Anchor(id=f"{bid}_out", block_id=bid, type=EAnchorType.OUT),
232
+ ],
233
+ )
234
+ )
235
+ ids.append(bid)
236
+ layer_ids.append(ids)
237
+
238
+ connections: list[Connection] = []
239
+ for layer in range(layers_count - 1):
240
+ src_ids = layer_ids[layer]
241
+ dst_ids = layer_ids[layer + 1]
242
+ for _ in range(connections_per_layer):
243
+ s = rng.choice(src_ids)
244
+ t = rng.choice(dst_ids)
245
+ connections.append(
246
+ Connection(
247
+ source_block_id=s, source_anchor_id=f"{s}_out",
248
+ target_block_id=t, target_anchor_id=f"{t}_in",
249
+ dashed=dashed,
250
+ )
251
+ )
252
+ return GraphData(blocks=blocks, connections=connections)
253
+
254
+
255
+ # --------------------------------------------------------------------------- #
256
+ # Grouping example (the "Canvas/Groups" stories)
257
+ # --------------------------------------------------------------------------- #
258
+
259
+
260
+ def grouped_graph(groups: int = 3, per_group: int = 4) -> GraphData:
261
+ """Build a grouped graph by assigning blocks to ``group`` ids."""
262
+ blocks: list[Block] = []
263
+ connections: list[Connection] = []
264
+ for g in range(groups):
265
+ prev: str | None = None
266
+ for i in range(per_group):
267
+ bid = f"g{g}_b{i}"
268
+ blocks.append(
269
+ Block(
270
+ id=bid, group=f"group_{g}",
271
+ x=g * 420, y=i * 160, width=150, height=90,
272
+ name=f"G{g} · {i}",
273
+ anchors=[
274
+ Anchor(id=f"{bid}_in", block_id=bid, type=EAnchorType.IN),
275
+ Anchor(id=f"{bid}_out", block_id=bid, type=EAnchorType.OUT),
276
+ ],
277
+ )
278
+ )
279
+ if prev is not None:
280
+ connections.append(
281
+ Connection(
282
+ source_block_id=prev, source_anchor_id=f"{prev}_out",
283
+ target_block_id=bid, target_anchor_id=f"{bid}_in",
284
+ )
285
+ )
286
+ prev = bid
287
+ return GraphData(blocks=blocks, connections=connections)
@@ -0,0 +1,166 @@
1
+ Metadata-Version: 2.4
2
+ Name: reflex-gravityui-graph
3
+ Version: 0.1.0
4
+ Summary: A Reflex custom component wrapping @gravity-ui/graph (a high-performance Canvas + React graph/flowchart/node-editor visualization library).
5
+ Author-email: Ernesto Crespo <ecrespo@gmail.com>
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/ecrespo/reflex-gravityui-graph
8
+ Project-URL: Repository, https://github.com/ecrespo/reflex-gravityui-graph
9
+ Project-URL: Issues, https://github.com/ecrespo/reflex-gravityui-graph/issues
10
+ Project-URL: @gravity-ui/graph, https://github.com/gravity-ui/graph
11
+ Keywords: reflex,reflex-custom-components,gravity-ui,graph,flowchart,node-editor,diagram,canvas,visualization,component
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Framework :: AsyncIO
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: Apache Software License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: reflex>=0.8.0
22
+ Provides-Extra: dev
23
+ Requires-Dist: build; extra == "dev"
24
+ Requires-Dist: twine; extra == "dev"
25
+ Requires-Dist: pytest; extra == "dev"
26
+ Dynamic: license-file
27
+
28
+ # reflex-gravityui-graph
29
+
30
+ A [Reflex](https://reflex.dev) custom component that wraps
31
+ [`@gravity-ui/graph`](https://github.com/gravity-ui/graph) — a high-performance
32
+ graph / flowchart / node-editor visualization library that renders the full
33
+ graph on **Canvas** when zoomed out (smooth with thousands of nodes) and swaps
34
+ to **HTML/React** for rich interactions when zoomed in.
35
+
36
+ It exposes the whole graph declaratively from Python: pass `blocks`,
37
+ `connections`, `settings`, `colors` and `constants` as plain data, and receive
38
+ selection / connection / camera events back into your Reflex state.
39
+
40
+ > Status: **0.1.0 (beta).** Wraps `@gravity-ui/graph@1.11.0`.
41
+
42
+ ---
43
+
44
+ ## Installation
45
+
46
+ ```bash
47
+ uv add reflex-gravityui-graph
48
+ # or
49
+ pip install reflex-gravityui-graph
50
+ ```
51
+
52
+ The first time the demo (or your app) runs, Reflex installs the npm dependency
53
+ `@gravity-ui/graph` automatically.
54
+
55
+ ## Quick start
56
+
57
+ ```python
58
+ import reflex as rx
59
+ from reflex_gravityui_graph import graph_canvas, Block, Connection, Anchor, EAnchorType
60
+
61
+ a = Block(id="a", x=0, y=0, name="Start",
62
+ anchors=[Anchor(id="out", block_id="a", type=EAnchorType.OUT)])
63
+ b = Block(id="b", x=320, y=180, name="End",
64
+ anchors=[Anchor(id="in", block_id="b", type=EAnchorType.IN)])
65
+ edge = Connection(source_block_id="a", source_anchor_id="out",
66
+ target_block_id="b", target_anchor_id="in")
67
+
68
+
69
+ def index() -> rx.Component:
70
+ return graph_canvas(
71
+ blocks=[a.to_dict(), b.to_dict()],
72
+ connections=[edge.to_dict()],
73
+ width="100%",
74
+ height="600px",
75
+ )
76
+
77
+
78
+ app = rx.App()
79
+ app.add_page(index)
80
+ ```
81
+
82
+ `GraphCanvas` fills its container, so always give the component an explicit
83
+ height (`height="600px"`) or a sized parent.
84
+
85
+ ## Why a JSX bridge?
86
+
87
+ `@gravity-ui/graph`'s React API is **hook + imperative**: you create a `Graph`
88
+ with the `useGraph` hook, push entities with `setEntities`, and `start()` the
89
+ render loop once the canvas is attached. Reflex components can't call React hooks
90
+ directly, so this package ships a tiny local bridge (`graph_canvas.jsx`) that
91
+ encapsulates that lifecycle and presents a flat, declarative, JSON-serializable
92
+ prop surface. See [`docs/sdd/02-architecture.md`](docs/sdd/02-architecture.md).
93
+
94
+ ## API
95
+
96
+ ### `graph_canvas(...)` / `GravityGraph`
97
+
98
+ | Prop | Type | Description |
99
+ | --- | --- | --- |
100
+ | `blocks` | `list[dict]` | Nodes (`id`, `is`, `x`, `y`, `width`, `height`, `name`, optional `anchors`, `selected`, `group`, `meta`). |
101
+ | `connections` | `list[dict]` | Edges (`sourceBlockId`/`targetBlockId`, optional anchor ids, `label`, `styles`, `dashed`, `selected`). |
102
+ | `settings` | `dict` | `TGraphSettingsConfig` (bezier, arrows, labels, drag/zoom, anchors, …). |
103
+ | `colors` | `dict` | `RecursivePartial<TGraphColors>` view colors. |
104
+ | `constants` | `dict` | `RecursivePartial<TGraphConstants>` view constants. |
105
+ | `config_name` | `str` | Underlying graph configuration name. |
106
+ | `auto_start` | `bool` | Start the render loop on attach (default `True`). |
107
+ | `zoom_on_start` | `bool` | Fit camera to graph on attach (default `True`). |
108
+ | `zoom_target` | `str` \| list | `"center"`, a rect, or a list of block ids. |
109
+ | `zoom_padding` | `int` | Padding (px) when fitting on start. |
110
+ | `render_html_blocks` | `bool` | Render rich HTML/React blocks at detailed zoom. |
111
+
112
+ **Events** (each delivers one plain dict to your handler):
113
+
114
+ `on_block_selection_change`, `on_connection_created`,
115
+ `on_connection_selection_change`, `on_camera_change`, `on_block_drag_end`,
116
+ `on_state_change`.
117
+
118
+ ### Domain value objects
119
+
120
+ `Block`, `Connection`, `Anchor`, `EAnchorType`, `GraphData` build and validate
121
+ the entity payloads in Python (duplicate-id and dangling-edge checks included).
122
+
123
+ ### Recipe helpers (reproduce the upstream Storybook stories)
124
+
125
+ `one_block`, `two_connected_blocks`, `colored_connections`, `block_with_anchors`,
126
+ `generate_layered_blocks` (hundred / thousand / five-thousand block graphs),
127
+ `grouped_graph`, `default_settings`, and the `THEMES` color presets.
128
+
129
+ ## Demo app
130
+
131
+ A full demo with one page per example category lives in
132
+ `reflex_gravityui_graph_demo/`:
133
+
134
+ ```bash
135
+ uv pip install -e .
136
+ cd reflex_gravityui_graph_demo
137
+ reflex run
138
+ ```
139
+
140
+ Pages: Basics · Connections · Scale · Grouping · Camera & Zoom · Theming ·
141
+ Interaction & Events.
142
+
143
+ ## Development
144
+
145
+ This project uses [`uv`](https://docs.astral.sh/uv/).
146
+
147
+ ```bash
148
+ uv venv && source .venv/bin/activate
149
+ uv pip install -e ".[dev]"
150
+ uv run pytest -q # run the test suite
151
+ uv run reflex component build # build wheel/sdist + generate the .pyi stub
152
+ uv publish # publish to PyPI (needs an API token)
153
+ ```
154
+
155
+ See [`docs/sdd/`](docs/sdd/) for the full Spec-Driven Design artifacts
156
+ (overview, PRD, architecture, component spec, plan, tasks) and
157
+ [`docs/research/`](docs/research/) for the upstream research notes.
158
+
159
+ ## Credits
160
+
161
+ - [`@gravity-ui/graph`](https://github.com/gravity-ui/graph) by Gravity UI (MIT).
162
+ - Built as a [Reflex custom component](https://reflex.dev/docs/custom-components/overview/).
163
+
164
+ ## License
165
+
166
+ Apache-2.0 © Ernesto Crespo. `@gravity-ui/graph` is MIT © YANDEX LLC.
@@ -0,0 +1,11 @@
1
+ reflex_gravityui_graph/__init__.py,sha256=1KgCebxi3WTZPoJKlULHboBv4YSvCG3RLcZGWhkJtjg,2399
2
+ reflex_gravityui_graph/domain.py,sha256=6oGRtYbnW4hbGS7AUJq7VsSPRqLHZRIcfYBP0yaYqX0,7199
3
+ reflex_gravityui_graph/graph.py,sha256=9OBu2bXPPwbwbj44Pgh97zng8vuLOZyaOdjlO5VDB9c,6063
4
+ reflex_gravityui_graph/graph.pyi,sha256=PY097LykBBwg67zQxsWO3JhRV9aJi5QovV79eT3E3DU,7640
5
+ reflex_gravityui_graph/graph_canvas.jsx,sha256=uF1VpBSgT5O5DedyYOlQfsqR470yxrSpJ8ipabZsaoI,6058
6
+ reflex_gravityui_graph/recipes.py,sha256=3VqJtTJInuIohME-OjJOtdLhuKoGSGM2gWMBr2NDVp0,11281
7
+ reflex_gravityui_graph-0.1.0.dist-info/licenses/LICENSE,sha256=C32kaF_Nae7AJDNsNGk24KPnrwvRKrMUbYeGHjcGjgw,9994
8
+ reflex_gravityui_graph-0.1.0.dist-info/METADATA,sha256=dKp6doFz9A2VIwwRv8izhhS0G6G4Kqwy96hxR_hDZBk,6369
9
+ reflex_gravityui_graph-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
10
+ reflex_gravityui_graph-0.1.0.dist-info/top_level.txt,sha256=Nx8HV-kHTzvUkZkz_7YG8YXNZaSZSrx-wzxFrCfvNeM,23
11
+ reflex_gravityui_graph-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+