voidmesh 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.
- voidmesh/__init__.py +7 -0
- voidmesh/canvas.py +439 -0
- voidmesh/demo.py +23 -0
- voidmesh/layout.py +92 -0
- voidmesh/models.py +41 -0
- voidmesh/physics.py +99 -0
- voidmesh/py.typed +0 -0
- voidmesh/renderer.py +545 -0
- voidmesh-0.1.0.dist-info/METADATA +115 -0
- voidmesh-0.1.0.dist-info/RECORD +13 -0
- voidmesh-0.1.0.dist-info/WHEEL +4 -0
- voidmesh-0.1.0.dist-info/entry_points.txt +3 -0
- voidmesh-0.1.0.dist-info/licenses/LICENSE +21 -0
voidmesh/__init__.py
ADDED
voidmesh/canvas.py
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
"""The graph model and its live-window lifecycle."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import random
|
|
7
|
+
import threading
|
|
8
|
+
import uuid
|
|
9
|
+
from collections.abc import Iterator
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from dataclasses import replace
|
|
12
|
+
from typing import Any, Self
|
|
13
|
+
|
|
14
|
+
from .layout import TreeLayout
|
|
15
|
+
from .models import Color, Link, Node
|
|
16
|
+
from .physics import Physics
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Canvas:
|
|
20
|
+
"""A mutable graph displayed on a clean, physics-based canvas.
|
|
21
|
+
|
|
22
|
+
Graph operations are thread-safe. Call ``show(block=False)`` to keep
|
|
23
|
+
executing Python while the window reflects later changes in real time.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
*,
|
|
29
|
+
title: str = "Voidmesh",
|
|
30
|
+
width: int = 1000,
|
|
31
|
+
height: int = 700,
|
|
32
|
+
background: Color = "#090b12",
|
|
33
|
+
center_force: float = 0.12,
|
|
34
|
+
repel_force: float = 4.0,
|
|
35
|
+
link_force: float = 0.35,
|
|
36
|
+
link_distance: float = 150.0,
|
|
37
|
+
damping: float = 0.9,
|
|
38
|
+
layout: str = "force",
|
|
39
|
+
tree_horizontal_spacing: float = 150.0,
|
|
40
|
+
tree_vertical_spacing: float = 110.0,
|
|
41
|
+
grid: bool = False,
|
|
42
|
+
grid_color: Color = "#30384a",
|
|
43
|
+
controls: bool = True,
|
|
44
|
+
ui_scale: float | None = None,
|
|
45
|
+
node_font: str = "sans",
|
|
46
|
+
node_font_size: float = 15.0,
|
|
47
|
+
node_padding: tuple[float, float] = (12.0, 7.0),
|
|
48
|
+
node_roundness: float = 9.0,
|
|
49
|
+
link_width: float = 1.5,
|
|
50
|
+
fps: int = 60,
|
|
51
|
+
gravity: float | None = None,
|
|
52
|
+
repulsion: float | None = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
if width < 200 or height < 200:
|
|
55
|
+
raise ValueError("width and height must be at least 200")
|
|
56
|
+
if layout not in {"force", "tree"}:
|
|
57
|
+
raise ValueError("layout must be 'force' or 'tree'")
|
|
58
|
+
if node_font_size <= 0 or node_roundness < 0 or link_width <= 0:
|
|
59
|
+
raise ValueError(
|
|
60
|
+
"font size/link width must be positive; roundness cannot be negative"
|
|
61
|
+
)
|
|
62
|
+
if len(node_padding) != 2 or min(node_padding) < 0:
|
|
63
|
+
raise ValueError("node_padding must contain two non-negative values")
|
|
64
|
+
self.title = title
|
|
65
|
+
self.width = width
|
|
66
|
+
self.height = height
|
|
67
|
+
self.background = background
|
|
68
|
+
self.grid = grid
|
|
69
|
+
self.grid_color = grid_color
|
|
70
|
+
self.controls = controls
|
|
71
|
+
self.layout = layout
|
|
72
|
+
if ui_scale is not None and ui_scale <= 0:
|
|
73
|
+
raise ValueError("ui_scale must be positive")
|
|
74
|
+
self.ui_scale = ui_scale
|
|
75
|
+
self.node_font = node_font
|
|
76
|
+
self.node_font_size = float(node_font_size)
|
|
77
|
+
self.node_padding = (float(node_padding[0]), float(node_padding[1]))
|
|
78
|
+
self.node_roundness = float(node_roundness)
|
|
79
|
+
self.default_link_width = float(link_width)
|
|
80
|
+
self.fps = fps
|
|
81
|
+
self.nodes: dict[str, Node] = {}
|
|
82
|
+
self.links: dict[str, Link] = {}
|
|
83
|
+
self.physics = Physics(
|
|
84
|
+
center_force=center_force,
|
|
85
|
+
repel_force=repel_force,
|
|
86
|
+
link_force=link_force,
|
|
87
|
+
link_distance=link_distance,
|
|
88
|
+
damping=damping,
|
|
89
|
+
gravity=gravity,
|
|
90
|
+
repulsion=repulsion,
|
|
91
|
+
)
|
|
92
|
+
self.tree_layout = TreeLayout(
|
|
93
|
+
horizontal_spacing=tree_horizontal_spacing,
|
|
94
|
+
vertical_spacing=tree_vertical_spacing,
|
|
95
|
+
)
|
|
96
|
+
self._lock = threading.RLock()
|
|
97
|
+
self._window_thread: threading.Thread | None = None
|
|
98
|
+
self._renderer: Any = None
|
|
99
|
+
self._ready = threading.Event()
|
|
100
|
+
self._window_error: BaseException | None = None
|
|
101
|
+
|
|
102
|
+
def add_node(
|
|
103
|
+
self,
|
|
104
|
+
value: object = "",
|
|
105
|
+
*,
|
|
106
|
+
node_id: str | None = None,
|
|
107
|
+
color: Color = "#a78bfa",
|
|
108
|
+
position: tuple[float, float] | None = None,
|
|
109
|
+
radius: float = 10.0,
|
|
110
|
+
fixed: bool = False,
|
|
111
|
+
font: str | None = None,
|
|
112
|
+
font_size: float | None = None,
|
|
113
|
+
) -> Node:
|
|
114
|
+
"""Add and return a node."""
|
|
115
|
+
node_id = node_id or f"node-{uuid.uuid4().hex[:8]}"
|
|
116
|
+
if radius <= 0:
|
|
117
|
+
raise ValueError("radius must be positive")
|
|
118
|
+
if font_size is not None and font_size <= 0:
|
|
119
|
+
raise ValueError("font_size must be positive")
|
|
120
|
+
if position is None:
|
|
121
|
+
angle = random.random() * math.tau
|
|
122
|
+
distance = random.uniform(20.0, 85.0)
|
|
123
|
+
position = math.cos(angle) * distance, math.sin(angle) * distance
|
|
124
|
+
with self._lock:
|
|
125
|
+
if node_id in self.nodes:
|
|
126
|
+
raise ValueError(f"node {node_id!r} already exists")
|
|
127
|
+
node = Node(
|
|
128
|
+
id=node_id,
|
|
129
|
+
value=value,
|
|
130
|
+
color=color,
|
|
131
|
+
x=float(position[0]),
|
|
132
|
+
y=float(position[1]),
|
|
133
|
+
radius=float(radius),
|
|
134
|
+
fixed=fixed,
|
|
135
|
+
font=font,
|
|
136
|
+
font_size=float(font_size) if font_size is not None else None,
|
|
137
|
+
)
|
|
138
|
+
self.nodes[node_id] = node
|
|
139
|
+
return node
|
|
140
|
+
|
|
141
|
+
def update_node(self, node: str | Node, **changes: object) -> Node:
|
|
142
|
+
"""Change a node's value, color, position, radius, or fixed state."""
|
|
143
|
+
allowed = {"value", "color", "position", "radius", "fixed", "font", "font_size"}
|
|
144
|
+
unknown = changes.keys() - allowed
|
|
145
|
+
if unknown:
|
|
146
|
+
raise TypeError(f"unknown node properties: {', '.join(sorted(unknown))}")
|
|
147
|
+
with self._lock:
|
|
148
|
+
current = self._get_node(node)
|
|
149
|
+
if "position" in changes:
|
|
150
|
+
position = changes.pop("position")
|
|
151
|
+
if not isinstance(position, (tuple, list)) or len(position) != 2:
|
|
152
|
+
raise ValueError("position must contain two numbers")
|
|
153
|
+
current.x, current.y = float(position[0]), float(position[1])
|
|
154
|
+
current.vx = current.vy = 0.0
|
|
155
|
+
if "radius" in changes and float(changes["radius"]) <= 0:
|
|
156
|
+
raise ValueError("radius must be positive")
|
|
157
|
+
if "radius" in changes:
|
|
158
|
+
changes["radius"] = float(changes["radius"])
|
|
159
|
+
if "font_size" in changes and changes["font_size"] is not None:
|
|
160
|
+
if float(changes["font_size"]) <= 0:
|
|
161
|
+
raise ValueError("font_size must be positive")
|
|
162
|
+
changes["font_size"] = float(changes["font_size"])
|
|
163
|
+
for name, value in changes.items():
|
|
164
|
+
setattr(current, name, value)
|
|
165
|
+
return current
|
|
166
|
+
|
|
167
|
+
def delete_node(self, node: str | Node) -> Node:
|
|
168
|
+
"""Delete a node and all links attached to it."""
|
|
169
|
+
with self._lock:
|
|
170
|
+
current = self._get_node(node)
|
|
171
|
+
attached = [
|
|
172
|
+
link.id
|
|
173
|
+
for link in self.links.values()
|
|
174
|
+
if current.id in (link.source, link.target)
|
|
175
|
+
]
|
|
176
|
+
for link_id in attached:
|
|
177
|
+
del self.links[link_id]
|
|
178
|
+
return self.nodes.pop(current.id)
|
|
179
|
+
|
|
180
|
+
remove_node = delete_node
|
|
181
|
+
|
|
182
|
+
def link(
|
|
183
|
+
self,
|
|
184
|
+
source: str | Node,
|
|
185
|
+
target: str | Node,
|
|
186
|
+
*,
|
|
187
|
+
link_id: str | None = None,
|
|
188
|
+
color: Color = "#64748b",
|
|
189
|
+
width: float | None = None,
|
|
190
|
+
length: float = 115.0,
|
|
191
|
+
elasticity: float = 0.035,
|
|
192
|
+
) -> Link:
|
|
193
|
+
"""Create and return an elastic link between two nodes."""
|
|
194
|
+
width = self.default_link_width if width is None else width
|
|
195
|
+
if width <= 0 or length <= 0 or elasticity < 0:
|
|
196
|
+
raise ValueError("width/length must be positive; elasticity cannot be negative")
|
|
197
|
+
with self._lock:
|
|
198
|
+
source_node = self._get_node(source)
|
|
199
|
+
target_node = self._get_node(target)
|
|
200
|
+
link_id = link_id or f"link-{uuid.uuid4().hex[:8]}"
|
|
201
|
+
if link_id in self.links:
|
|
202
|
+
raise ValueError(f"link {link_id!r} already exists")
|
|
203
|
+
link = Link(
|
|
204
|
+
id=link_id,
|
|
205
|
+
source=source_node.id,
|
|
206
|
+
target=target_node.id,
|
|
207
|
+
color=color,
|
|
208
|
+
width=float(width),
|
|
209
|
+
length=float(length),
|
|
210
|
+
elasticity=float(elasticity),
|
|
211
|
+
)
|
|
212
|
+
self.links[link_id] = link
|
|
213
|
+
return link
|
|
214
|
+
|
|
215
|
+
def update_link(self, link: str | Link, **changes: object) -> Link:
|
|
216
|
+
"""Change a link's color, width, length, or elasticity."""
|
|
217
|
+
allowed = {"color", "width", "length", "elasticity"}
|
|
218
|
+
unknown = changes.keys() - allowed
|
|
219
|
+
if unknown:
|
|
220
|
+
raise TypeError(f"unknown link properties: {', '.join(sorted(unknown))}")
|
|
221
|
+
if "width" in changes and float(changes["width"]) <= 0:
|
|
222
|
+
raise ValueError("width must be positive")
|
|
223
|
+
if "length" in changes and float(changes["length"]) <= 0:
|
|
224
|
+
raise ValueError("length must be positive")
|
|
225
|
+
if "elasticity" in changes and float(changes["elasticity"]) < 0:
|
|
226
|
+
raise ValueError("elasticity cannot be negative")
|
|
227
|
+
with self._lock:
|
|
228
|
+
current = self._get_link(link)
|
|
229
|
+
for name, value in changes.items():
|
|
230
|
+
setattr(current, name, float(value) if name != "color" else value)
|
|
231
|
+
return current
|
|
232
|
+
|
|
233
|
+
def unlink(
|
|
234
|
+
self, link_or_source: str | Node | Link, target: str | Node | None = None
|
|
235
|
+
) -> Link:
|
|
236
|
+
"""Remove a link by link/id, or remove the first link between two nodes."""
|
|
237
|
+
with self._lock:
|
|
238
|
+
if target is None:
|
|
239
|
+
link = self._get_link(link_or_source)
|
|
240
|
+
else:
|
|
241
|
+
source_id = self._get_node(link_or_source).id
|
|
242
|
+
target_id = self._get_node(target).id
|
|
243
|
+
link = next(
|
|
244
|
+
(
|
|
245
|
+
item
|
|
246
|
+
for item in self.links.values()
|
|
247
|
+
if {item.source, item.target} == {source_id, target_id}
|
|
248
|
+
),
|
|
249
|
+
None,
|
|
250
|
+
)
|
|
251
|
+
if link is None:
|
|
252
|
+
raise KeyError(f"no link between {source_id!r} and {target_id!r}")
|
|
253
|
+
return self.links.pop(link.id)
|
|
254
|
+
|
|
255
|
+
delete_link = unlink
|
|
256
|
+
|
|
257
|
+
def relink(
|
|
258
|
+
self,
|
|
259
|
+
link: str | Link,
|
|
260
|
+
*,
|
|
261
|
+
source: str | Node | None = None,
|
|
262
|
+
target: str | Node | None = None,
|
|
263
|
+
) -> Link:
|
|
264
|
+
"""Move either end of an existing link."""
|
|
265
|
+
with self._lock:
|
|
266
|
+
current = self._get_link(link)
|
|
267
|
+
if source is not None:
|
|
268
|
+
current.source = self._get_node(source).id
|
|
269
|
+
if target is not None:
|
|
270
|
+
current.target = self._get_node(target).id
|
|
271
|
+
return current
|
|
272
|
+
|
|
273
|
+
def clear(self) -> None:
|
|
274
|
+
"""Remove every node and link."""
|
|
275
|
+
with self._lock:
|
|
276
|
+
self.nodes.clear()
|
|
277
|
+
self.links.clear()
|
|
278
|
+
|
|
279
|
+
def configure_physics(
|
|
280
|
+
self,
|
|
281
|
+
*,
|
|
282
|
+
center_force: float | None = None,
|
|
283
|
+
repel_force: float | None = None,
|
|
284
|
+
link_force: float | None = None,
|
|
285
|
+
link_distance: float | None = None,
|
|
286
|
+
damping: float | None = None,
|
|
287
|
+
) -> None:
|
|
288
|
+
"""Adjust the live force simulation programmatically."""
|
|
289
|
+
changes = {
|
|
290
|
+
"center_force": center_force,
|
|
291
|
+
"repel_force": repel_force,
|
|
292
|
+
"link_force": link_force,
|
|
293
|
+
"link_distance": link_distance,
|
|
294
|
+
"damping": damping,
|
|
295
|
+
}
|
|
296
|
+
with self._lock:
|
|
297
|
+
for name, value in changes.items():
|
|
298
|
+
if value is None:
|
|
299
|
+
continue
|
|
300
|
+
if value < 0 or (name == "link_distance" and value == 0):
|
|
301
|
+
raise ValueError(f"{name} must be positive or zero")
|
|
302
|
+
setattr(self.physics, name, float(value))
|
|
303
|
+
|
|
304
|
+
def toggle_grid(self, visible: bool | None = None) -> bool:
|
|
305
|
+
"""Toggle the background grid and return its new visibility."""
|
|
306
|
+
with self._lock:
|
|
307
|
+
self.grid = not self.grid if visible is None else bool(visible)
|
|
308
|
+
return self.grid
|
|
309
|
+
|
|
310
|
+
def set_layout(self, layout: str) -> None:
|
|
311
|
+
"""Switch between the live ``force`` view and systematic ``tree`` view."""
|
|
312
|
+
if layout not in {"force", "tree"}:
|
|
313
|
+
raise ValueError("layout must be 'force' or 'tree'")
|
|
314
|
+
with self._lock:
|
|
315
|
+
self.layout = layout
|
|
316
|
+
if layout == "tree":
|
|
317
|
+
self.tree_layout.apply(self.nodes, self.links.values())
|
|
318
|
+
|
|
319
|
+
def configure_style(
|
|
320
|
+
self,
|
|
321
|
+
*,
|
|
322
|
+
node_font: str | None = None,
|
|
323
|
+
node_font_size: float | None = None,
|
|
324
|
+
node_padding: tuple[float, float] | None = None,
|
|
325
|
+
node_roundness: float | None = None,
|
|
326
|
+
link_width: float | None = None,
|
|
327
|
+
grid_color: Color | None = None,
|
|
328
|
+
) -> None:
|
|
329
|
+
"""Change the graph's visual defaults while its window is open."""
|
|
330
|
+
if node_font_size is not None and node_font_size <= 0:
|
|
331
|
+
raise ValueError("node_font_size must be positive")
|
|
332
|
+
if node_roundness is not None and node_roundness < 0:
|
|
333
|
+
raise ValueError("node_roundness cannot be negative")
|
|
334
|
+
if node_padding is not None and (len(node_padding) != 2 or min(node_padding) < 0):
|
|
335
|
+
raise ValueError("node_padding must contain two non-negative values")
|
|
336
|
+
if link_width is not None and link_width <= 0:
|
|
337
|
+
raise ValueError("link_width must be positive")
|
|
338
|
+
with self._lock:
|
|
339
|
+
if node_font is not None:
|
|
340
|
+
self.node_font = node_font
|
|
341
|
+
if node_font_size is not None:
|
|
342
|
+
self.node_font_size = float(node_font_size)
|
|
343
|
+
if node_padding is not None:
|
|
344
|
+
self.node_padding = (float(node_padding[0]), float(node_padding[1]))
|
|
345
|
+
if node_roundness is not None:
|
|
346
|
+
self.node_roundness = float(node_roundness)
|
|
347
|
+
if link_width is not None:
|
|
348
|
+
self.default_link_width = float(link_width)
|
|
349
|
+
for link in self.links.values():
|
|
350
|
+
link.width = float(link_width)
|
|
351
|
+
if grid_color is not None:
|
|
352
|
+
self.grid_color = grid_color
|
|
353
|
+
|
|
354
|
+
def snapshot(self) -> tuple[tuple[Node, ...], tuple[Link, ...]]:
|
|
355
|
+
"""Return detached copies of the current graph state."""
|
|
356
|
+
with self._lock:
|
|
357
|
+
return (
|
|
358
|
+
tuple(replace(node) for node in self.nodes.values()),
|
|
359
|
+
tuple(replace(link) for link in self.links.values()),
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
@contextmanager
|
|
363
|
+
def _locked_graph(self) -> Iterator[tuple[dict[str, Node], dict[str, Link]]]:
|
|
364
|
+
with self._lock:
|
|
365
|
+
yield self.nodes, self.links
|
|
366
|
+
|
|
367
|
+
def show(self, *, block: bool = True) -> Canvas:
|
|
368
|
+
"""Open the live canvas; non-blocking mode lets the script keep running."""
|
|
369
|
+
if self.is_open:
|
|
370
|
+
return self
|
|
371
|
+
self._ready.clear()
|
|
372
|
+
self._window_error = None
|
|
373
|
+
if block:
|
|
374
|
+
self._run_window()
|
|
375
|
+
else:
|
|
376
|
+
self._window_thread = threading.Thread(
|
|
377
|
+
target=self._run_window, name="voidmesh-window", daemon=True
|
|
378
|
+
)
|
|
379
|
+
self._window_thread.start()
|
|
380
|
+
ready = self._ready.wait(timeout=5.0)
|
|
381
|
+
if self._window_error is not None:
|
|
382
|
+
raise RuntimeError("could not open the voidmesh window") from self._window_error
|
|
383
|
+
if not ready:
|
|
384
|
+
self.close()
|
|
385
|
+
raise TimeoutError("the voidmesh window did not open within 5 seconds")
|
|
386
|
+
return self
|
|
387
|
+
|
|
388
|
+
run = show
|
|
389
|
+
|
|
390
|
+
def close(self) -> None:
|
|
391
|
+
"""Ask the live window to close."""
|
|
392
|
+
renderer = self._renderer
|
|
393
|
+
if renderer is not None:
|
|
394
|
+
renderer.stop()
|
|
395
|
+
thread = self._window_thread
|
|
396
|
+
if thread is not None and thread is not threading.current_thread():
|
|
397
|
+
thread.join(timeout=2.0)
|
|
398
|
+
|
|
399
|
+
def wait(self) -> None:
|
|
400
|
+
"""Wait for a non-blocking window to be closed by the user."""
|
|
401
|
+
thread = self._window_thread
|
|
402
|
+
if thread is not None and thread is not threading.current_thread():
|
|
403
|
+
thread.join()
|
|
404
|
+
|
|
405
|
+
@property
|
|
406
|
+
def is_open(self) -> bool:
|
|
407
|
+
return self._renderer is not None and self._renderer.running
|
|
408
|
+
|
|
409
|
+
def _run_window(self) -> None:
|
|
410
|
+
try:
|
|
411
|
+
from .renderer import Renderer
|
|
412
|
+
|
|
413
|
+
self._renderer = Renderer(self)
|
|
414
|
+
self._renderer.run()
|
|
415
|
+
except BaseException as error:
|
|
416
|
+
self._window_error = error
|
|
417
|
+
self._ready.set()
|
|
418
|
+
if self._window_thread is None:
|
|
419
|
+
raise
|
|
420
|
+
finally:
|
|
421
|
+
self._renderer = None
|
|
422
|
+
|
|
423
|
+
def _get_node(self, node: str | Node | Link) -> Node:
|
|
424
|
+
node_id = node.id if isinstance(node, Node) else node
|
|
425
|
+
if not isinstance(node_id, str) or node_id not in self.nodes:
|
|
426
|
+
raise KeyError(f"unknown node {node_id!r}")
|
|
427
|
+
return self.nodes[node_id]
|
|
428
|
+
|
|
429
|
+
def _get_link(self, link: str | Node | Link) -> Link:
|
|
430
|
+
link_id = link.id if isinstance(link, Link) else link
|
|
431
|
+
if not isinstance(link_id, str) or link_id not in self.links:
|
|
432
|
+
raise KeyError(f"unknown link {link_id!r}")
|
|
433
|
+
return self.links[link_id]
|
|
434
|
+
|
|
435
|
+
def __enter__(self) -> Self:
|
|
436
|
+
return self.show(block=False)
|
|
437
|
+
|
|
438
|
+
def __exit__(self, *_: object) -> None:
|
|
439
|
+
self.close()
|
voidmesh/demo.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""The bundled ``voidmesh-demo`` example."""
|
|
2
|
+
|
|
3
|
+
from .canvas import Canvas
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def main() -> None:
|
|
7
|
+
canvas = Canvas(title="Voidmesh — live graph")
|
|
8
|
+
center = canvas.add_node("Voidmesh", color="#f8fafc", radius=14, fixed=True)
|
|
9
|
+
ideas = [
|
|
10
|
+
("Nodes", "#a78bfa"),
|
|
11
|
+
("Links", "#38bdf8"),
|
|
12
|
+
("Physics", "#34d399"),
|
|
13
|
+
("Live", "#fb7185"),
|
|
14
|
+
("Python", "#fbbf24"),
|
|
15
|
+
]
|
|
16
|
+
for label, color in ideas:
|
|
17
|
+
node = canvas.add_node(label, color=color)
|
|
18
|
+
canvas.link(center, node, color=color, length=145)
|
|
19
|
+
canvas.show()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
if __name__ == "__main__":
|
|
23
|
+
main()
|
voidmesh/layout.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Deterministic, non-physical graph layouts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
|
|
7
|
+
from .models import Link, Node
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TreeLayout:
|
|
11
|
+
"""Lay directed source-to-target links out as a top-down forest."""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self, *, horizontal_spacing: float = 150, vertical_spacing: float = 110
|
|
15
|
+
) -> None:
|
|
16
|
+
self.horizontal_spacing = horizontal_spacing
|
|
17
|
+
self.vertical_spacing = vertical_spacing
|
|
18
|
+
|
|
19
|
+
def apply(self, nodes: dict[str, Node], links: Iterable[Link]) -> None:
|
|
20
|
+
if not nodes:
|
|
21
|
+
return
|
|
22
|
+
|
|
23
|
+
children = {node_id: [] for node_id in nodes}
|
|
24
|
+
parent: dict[str, str] = {}
|
|
25
|
+
for link in links:
|
|
26
|
+
if (
|
|
27
|
+
link.source in nodes
|
|
28
|
+
and link.target in nodes
|
|
29
|
+
and link.source != link.target
|
|
30
|
+
and link.target not in parent
|
|
31
|
+
):
|
|
32
|
+
parent[link.target] = link.source
|
|
33
|
+
children[link.source].append(link.target)
|
|
34
|
+
|
|
35
|
+
roots = [node_id for node_id in nodes if node_id not in parent]
|
|
36
|
+
if not roots:
|
|
37
|
+
roots = [next(iter(nodes))]
|
|
38
|
+
|
|
39
|
+
positions: dict[str, tuple[float, int]] = {}
|
|
40
|
+
placed: set[str] = set()
|
|
41
|
+
cursor = 0.0
|
|
42
|
+
max_depth = 0
|
|
43
|
+
|
|
44
|
+
def place(node_id: str, depth: int, left: float, visiting: set[str]) -> float:
|
|
45
|
+
nonlocal max_depth
|
|
46
|
+
if node_id in placed or node_id in visiting:
|
|
47
|
+
return 0.0
|
|
48
|
+
visiting.add(node_id)
|
|
49
|
+
placed.add(node_id)
|
|
50
|
+
max_depth = max(max_depth, depth)
|
|
51
|
+
valid_children = [child for child in children[node_id] if child not in placed]
|
|
52
|
+
own_width = max(
|
|
53
|
+
self.horizontal_spacing,
|
|
54
|
+
len(str(nodes[node_id].value)) * 9.0 + 54.0,
|
|
55
|
+
)
|
|
56
|
+
if not valid_children:
|
|
57
|
+
positions[node_id] = (left + own_width / 2, depth)
|
|
58
|
+
visiting.remove(node_id)
|
|
59
|
+
return own_width
|
|
60
|
+
|
|
61
|
+
child_left = left
|
|
62
|
+
child_centers: list[float] = []
|
|
63
|
+
total_width = 0.0
|
|
64
|
+
for child in valid_children:
|
|
65
|
+
width = place(child, depth + 1, child_left, visiting)
|
|
66
|
+
if width > 0:
|
|
67
|
+
child_centers.append(positions[child][0])
|
|
68
|
+
child_left += width
|
|
69
|
+
total_width += width
|
|
70
|
+
width = max(own_width, total_width)
|
|
71
|
+
center = (
|
|
72
|
+
sum(child_centers) / len(child_centers) if child_centers else left + width / 2
|
|
73
|
+
)
|
|
74
|
+
positions[node_id] = (center, depth)
|
|
75
|
+
visiting.remove(node_id)
|
|
76
|
+
return width
|
|
77
|
+
|
|
78
|
+
for root in roots:
|
|
79
|
+
width = place(root, 0, cursor, set())
|
|
80
|
+
cursor += width + self.horizontal_spacing * 0.6
|
|
81
|
+
for node_id in nodes:
|
|
82
|
+
if node_id not in placed:
|
|
83
|
+
width = place(node_id, 0, cursor, set())
|
|
84
|
+
cursor += width + self.horizontal_spacing * 0.6
|
|
85
|
+
|
|
86
|
+
total_width = max(cursor - self.horizontal_spacing * 0.6, 0.0)
|
|
87
|
+
y_offset = max_depth * self.vertical_spacing / 2
|
|
88
|
+
for node_id, (x, depth) in positions.items():
|
|
89
|
+
node = nodes[node_id]
|
|
90
|
+
node.x = x - total_width / 2
|
|
91
|
+
node.y = depth * self.vertical_spacing - y_offset
|
|
92
|
+
node.vx = node.vy = 0.0
|
voidmesh/models.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Public graph data types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
type Color = str | tuple[int, int, int]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(slots=True)
|
|
11
|
+
class Node:
|
|
12
|
+
"""A node on a :class:`voidmesh.Canvas`."""
|
|
13
|
+
|
|
14
|
+
id: str
|
|
15
|
+
value: object = ""
|
|
16
|
+
color: Color = "#a78bfa"
|
|
17
|
+
x: float = 0.0
|
|
18
|
+
y: float = 0.0
|
|
19
|
+
radius: float = 10.0
|
|
20
|
+
fixed: bool = False
|
|
21
|
+
font: str | None = None
|
|
22
|
+
font_size: float | None = None
|
|
23
|
+
vx: float = field(default=0.0, repr=False)
|
|
24
|
+
vy: float = field(default=0.0, repr=False)
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def position(self) -> tuple[float, float]:
|
|
28
|
+
return self.x, self.y
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(slots=True)
|
|
32
|
+
class Link:
|
|
33
|
+
"""An elastic connection between two nodes."""
|
|
34
|
+
|
|
35
|
+
id: str
|
|
36
|
+
source: str
|
|
37
|
+
target: str
|
|
38
|
+
color: Color = "#64748b"
|
|
39
|
+
width: float = 1.5
|
|
40
|
+
length: float = 115.0
|
|
41
|
+
elasticity: float = 0.035
|