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.
- reflex_gravityui_graph/__init__.py +93 -0
- reflex_gravityui_graph/domain.py +210 -0
- reflex_gravityui_graph/graph.py +146 -0
- reflex_gravityui_graph/graph.pyi +123 -0
- reflex_gravityui_graph/graph_canvas.jsx +178 -0
- reflex_gravityui_graph/recipes.py +287 -0
- reflex_gravityui_graph-0.1.0.dist-info/METADATA +166 -0
- reflex_gravityui_graph-0.1.0.dist-info/RECORD +11 -0
- reflex_gravityui_graph-0.1.0.dist-info/WHEEL +5 -0
- reflex_gravityui_graph-0.1.0.dist-info/licenses/LICENSE +179 -0
- reflex_gravityui_graph-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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,,
|