predictygraph 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.
- predictygraph/__init__.py +108 -0
- predictygraph/__main__.py +7 -0
- predictygraph/anim.py +91 -0
- predictygraph/cli.py +62 -0
- predictygraph/default.css +16 -0
- predictygraph/fonts/Carlito-Bold.ttf +0 -0
- predictygraph/fonts/Carlito-Regular.ttf +0 -0
- predictygraph/fonts/DejaVuSans-Bold.ttf +0 -0
- predictygraph/fonts/DejaVuSans.ttf +0 -0
- predictygraph/fonts/LiberationMono-Bold.ttf +0 -0
- predictygraph/fonts/LiberationMono-Regular.ttf +0 -0
- predictygraph/fonts/LiberationSans-Bold.ttf +0 -0
- predictygraph/fonts/LiberationSans-Regular.ttf +0 -0
- predictygraph/fonts/LiberationSerif-Bold.ttf +0 -0
- predictygraph/fonts/LiberationSerif-Regular.ttf +0 -0
- predictygraph/fonts.py +167 -0
- predictygraph/ir.py +62 -0
- predictygraph/layout.py +534 -0
- predictygraph/model.py +98 -0
- predictygraph/parser.py +310 -0
- predictygraph/render/__init__.py +17 -0
- predictygraph/render/apng.py +43 -0
- predictygraph/render/svg.py +121 -0
- predictygraph/route.py +453 -0
- predictygraph/safe.py +41 -0
- predictygraph/scene.py +349 -0
- predictygraph/style.py +204 -0
- predictygraph/text.py +80 -0
- predictygraph/theme.py +107 -0
- predictygraph/themes/catppuccin-frappe.toml +27 -0
- predictygraph/themes/catppuccin-latte.toml +27 -0
- predictygraph/themes/catppuccin-macchiato.toml +27 -0
- predictygraph/themes/catppuccin-mocha.toml +27 -0
- predictygraph/themes/default.toml +22 -0
- predictygraph/themes/dracula.toml +25 -0
- predictygraph/themes/github-light.toml +25 -0
- predictygraph/themes/gruvbox-dark.toml +24 -0
- predictygraph/themes/nord.toml +25 -0
- predictygraph/themes/solarized-dark.toml +15 -0
- predictygraph/themes/solarized-light.toml +24 -0
- predictygraph-0.1.0.dist-info/METADATA +60 -0
- predictygraph-0.1.0.dist-info/RECORD +44 -0
- predictygraph-0.1.0.dist-info/WHEEL +4 -0
- predictygraph-0.1.0.dist-info/entry_points.txt +6 -0
predictygraph/layout.py
ADDED
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 The predictygraph Authors
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
"""Hierarchical layout: each container is laid out independently (inside-out)
|
|
6
|
+
with its own balancing style, then treated as a single box by its parent."""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections import defaultdict
|
|
10
|
+
|
|
11
|
+
from .model import Document, Group, Item, Node
|
|
12
|
+
from .style import box4, px
|
|
13
|
+
from .text import load_image, measure
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ---------------------------------------------------------------- sizing
|
|
17
|
+
def size_node(n: Node, doc: Document) -> None:
|
|
18
|
+
st = n.style
|
|
19
|
+
fs = px(st.get("font-size"), 14)
|
|
20
|
+
bold = st.get("font-weight", "normal") in ("bold", "600", "700", "800", "900")
|
|
21
|
+
label = n.label if n.label is not None else n.id
|
|
22
|
+
n.text_w, n.text_h, n.text_lines = measure(label, st.get("font-family", "sans-serif"), fs, bold) if label else (0.0, 0.0, [])
|
|
23
|
+
pt, pr, pb, pl = box4(st.get("padding"), 8)
|
|
24
|
+
shape = n.attrs.get("shape")
|
|
25
|
+
if "image" in n.attrs:
|
|
26
|
+
img = load_image(n.attrs["image"], doc.base_dir, doc.allow_outside)
|
|
27
|
+
iw, ih = img["w"], img["h"]
|
|
28
|
+
if "img-width" in n.attrs or "img-height" in n.attrs:
|
|
29
|
+
tw, th = max(0.0, px(n.attrs.get("img-width"), 0)), max(0.0, px(n.attrs.get("img-height"), 0))
|
|
30
|
+
if tw and th:
|
|
31
|
+
iw, ih = tw, th
|
|
32
|
+
elif tw:
|
|
33
|
+
iw, ih = tw, ih * tw / iw
|
|
34
|
+
else:
|
|
35
|
+
iw, ih = iw * th / ih, th
|
|
36
|
+
else:
|
|
37
|
+
s = min(1.0, 96 / max(iw, ih))
|
|
38
|
+
iw, ih = iw * s, ih * s
|
|
39
|
+
img["dw"], img["dh"] = iw, ih
|
|
40
|
+
n.image = img
|
|
41
|
+
shape = shape or "image"
|
|
42
|
+
gap = 4 if n.text_lines else 0
|
|
43
|
+
if shape == "image":
|
|
44
|
+
n.w = max(iw, n.text_w)
|
|
45
|
+
n.h = ih + gap + n.text_h
|
|
46
|
+
else:
|
|
47
|
+
n.w = max(iw, n.text_w) + pl + pr
|
|
48
|
+
n.h = ih + gap + n.text_h + pt + pb
|
|
49
|
+
else:
|
|
50
|
+
shape = shape or "box"
|
|
51
|
+
n.w = n.text_w + pl + pr
|
|
52
|
+
n.h = n.text_h + pt + pb
|
|
53
|
+
if shape in ("circle", "square"):
|
|
54
|
+
d = max(n.text_w, n.text_h) + max(pl, pt) * 1.2 if n.text_lines else 16.0
|
|
55
|
+
n.w = n.h = d
|
|
56
|
+
elif shape in ("ellipse",):
|
|
57
|
+
n.w, n.h = n.w * 1.25, n.h * 1.3
|
|
58
|
+
elif shape == "diamond":
|
|
59
|
+
n.w, n.h = n.w * 1.6, n.h * 1.7
|
|
60
|
+
elif shape == "hexagon":
|
|
61
|
+
n.w += n.h * 0.6
|
|
62
|
+
elif shape == "cylinder":
|
|
63
|
+
n.h += 12
|
|
64
|
+
n.attrs["shape"] = shape
|
|
65
|
+
explicit = (px(n.attrs.get("width"), 0), px(n.attrs.get("height"), 0))
|
|
66
|
+
mins = (0.0, 0.0) if shape in ("circle", "square") and not n.text_lines else \
|
|
67
|
+
(px(st.get("min-width"), 0), px(st.get("min-height"), 0))
|
|
68
|
+
n.w, n.h = max(n.w, mins[0], explicit[0]), max(n.h, mins[1], explicit[1])
|
|
69
|
+
if shape in ("circle", "square"):
|
|
70
|
+
n.w = n.h = max(n.w, n.h)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ---------------------------------------------------------------- helpers
|
|
74
|
+
def direct_child(item: Item, g: Group) -> Item | None:
|
|
75
|
+
"""Ancestor-or-self of `item` that is a direct child of g."""
|
|
76
|
+
cur = item
|
|
77
|
+
while cur is not None and cur.parent is not g:
|
|
78
|
+
cur = cur.parent
|
|
79
|
+
return cur
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def local_edges(g: Group, doc: Document):
|
|
83
|
+
"""Edges projected onto g's direct children: list of (src_child, dst_child, edge)."""
|
|
84
|
+
out = []
|
|
85
|
+
for e in doc.edges:
|
|
86
|
+
if e.attrs.get("constraint") == "false":
|
|
87
|
+
continue
|
|
88
|
+
a = direct_child(doc.items[e.src], g)
|
|
89
|
+
b = direct_child(doc.items[e.dst], g)
|
|
90
|
+
if a is not None and b is not None and a is not b:
|
|
91
|
+
out.append((a, b, e))
|
|
92
|
+
return out
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def opt(g: Group, key: str, default: str) -> str:
|
|
96
|
+
return g.attrs.get(key, default)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ---------------------------------------------------------------- layouts
|
|
100
|
+
class Layout:
|
|
101
|
+
def __init__(self, g: Group, doc: Document):
|
|
102
|
+
self.g, self.doc = g, doc
|
|
103
|
+
self.kids = g.children
|
|
104
|
+
|
|
105
|
+
def run(self) -> tuple[float, float]:
|
|
106
|
+
raise NotImplementedError
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class LineLayout(Layout):
|
|
110
|
+
"""row / column: single line with alignment."""
|
|
111
|
+
|
|
112
|
+
def run(self):
|
|
113
|
+
horizontal = opt(self.g, "layout", "row") == "row"
|
|
114
|
+
gap = px(opt(self.g, "gap", "24"))
|
|
115
|
+
align = opt(self.g, "align", "center")
|
|
116
|
+
cross = max((k.h if horizontal else k.w) for k in self.kids)
|
|
117
|
+
pos = 0.0
|
|
118
|
+
for k in self.kids:
|
|
119
|
+
size_c = k.h if horizontal else k.w
|
|
120
|
+
off = {"start": 0, "end": cross - size_c}.get(align, (cross - size_c) / 2)
|
|
121
|
+
if horizontal:
|
|
122
|
+
k.x, k.y = pos, off
|
|
123
|
+
pos += k.w + gap
|
|
124
|
+
else:
|
|
125
|
+
k.x, k.y = off, pos
|
|
126
|
+
pos += k.h + gap
|
|
127
|
+
pos -= gap
|
|
128
|
+
return (pos, cross) if horizontal else (cross, pos)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class GridLayout(Layout):
|
|
132
|
+
def run(self):
|
|
133
|
+
n = len(self.kids)
|
|
134
|
+
if "rows" in self.g.attrs and "cols" not in self.g.attrs:
|
|
135
|
+
rows = int(self.g.attrs["rows"])
|
|
136
|
+
cols = -(-n // rows)
|
|
137
|
+
else:
|
|
138
|
+
cols = int(opt(self.g, "cols", str(max(1, round(n ** 0.5)))))
|
|
139
|
+
gap = px(opt(self.g, "gap", "24"))
|
|
140
|
+
align = opt(self.g, "align", "center")
|
|
141
|
+
slots, taken, auto = {}, set(), []
|
|
142
|
+
for k in self.kids:
|
|
143
|
+
if "row" in k.attrs and "col" in k.attrs:
|
|
144
|
+
rc = (int(k.attrs["row"]), int(k.attrs["col"]))
|
|
145
|
+
slots[k.id] = rc
|
|
146
|
+
taken.add(rc)
|
|
147
|
+
else:
|
|
148
|
+
auto.append(k)
|
|
149
|
+
i = 0
|
|
150
|
+
for k in auto:
|
|
151
|
+
while (i // cols, i % cols) in taken:
|
|
152
|
+
i += 1
|
|
153
|
+
slots[k.id] = (i // cols, i % cols)
|
|
154
|
+
i += 1
|
|
155
|
+
nrows = max(r for r, _ in slots.values()) + 1
|
|
156
|
+
ncols = max(c for _, c in slots.values()) + 1
|
|
157
|
+
if opt(self.g, "uniform", "true") == "true":
|
|
158
|
+
cw = [max(k.w for k in self.kids)] * ncols
|
|
159
|
+
rh = [max(k.h for k in self.kids)] * nrows
|
|
160
|
+
else:
|
|
161
|
+
cw, rh = [0.0] * ncols, [0.0] * nrows
|
|
162
|
+
for k in self.kids:
|
|
163
|
+
r, c = slots[k.id]
|
|
164
|
+
cw[c], rh[r] = max(cw[c], k.w), max(rh[r], k.h)
|
|
165
|
+
xs = [sum(cw[:c]) + gap * c for c in range(ncols)]
|
|
166
|
+
ys = [sum(rh[:r]) + gap * r for r in range(nrows)]
|
|
167
|
+
for k in self.kids:
|
|
168
|
+
r, c = slots[k.id]
|
|
169
|
+
fx = {"start": 0, "end": cw[c] - k.w}.get(align, (cw[c] - k.w) / 2)
|
|
170
|
+
fy = (rh[r] - k.h) / 2
|
|
171
|
+
k.x, k.y = xs[c] + fx, ys[r] + fy
|
|
172
|
+
return xs[-1] + cw[-1], ys[-1] + rh[-1]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class FixedLayout(Layout):
|
|
176
|
+
def run(self):
|
|
177
|
+
for k in self.kids:
|
|
178
|
+
x, y = (k.attrs.get("at", "0,0").split(",") + ["0"])[:2]
|
|
179
|
+
k.x, k.y = float(x), float(y)
|
|
180
|
+
return max(k.x + k.w for k in self.kids), max(k.y + k.h for k in self.kids)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class RadialLayout(Layout):
|
|
184
|
+
def run(self):
|
|
185
|
+
import math
|
|
186
|
+
cid = opt(self.g, "center", self.kids[0].id)
|
|
187
|
+
center = next((k for k in self.kids if k.id == cid), self.kids[0])
|
|
188
|
+
ring = [k for k in self.kids if k is not center]
|
|
189
|
+
biggest = max((max(k.w, k.h) for k in ring), default=0)
|
|
190
|
+
circumference_need = sum(max(k.w, k.h) + 20 for k in ring)
|
|
191
|
+
r_auto = max(circumference_need / (2 * math.pi),
|
|
192
|
+
max(center.w, center.h) / 2 + max((min(k.w, k.h) for k in ring), default=0) / 2 + 36)
|
|
193
|
+
start = math.radians(px(opt(self.g, "start-angle", "-90"), -90))
|
|
194
|
+
angles = [start + 2 * math.pi * i / max(1, len(ring)) for i in range(len(ring))]
|
|
195
|
+
|
|
196
|
+
def support(k, a): # half-extent of a box along direction a
|
|
197
|
+
return abs(math.cos(a)) * k.w / 2 + abs(math.sin(a)) * k.h / 2
|
|
198
|
+
if ring:
|
|
199
|
+
r_auto = max(r_auto, max(support(center, a) + support(k, a) + 24 for k, a in zip(ring, angles)))
|
|
200
|
+
r = px(opt(self.g, "radius", "auto"), 0) or r_auto
|
|
201
|
+
pts = []
|
|
202
|
+
for k, a in zip(ring, angles):
|
|
203
|
+
pts.append((k, r * math.cos(a), r * math.sin(a)))
|
|
204
|
+
pts.append((center, 0.0, 0.0))
|
|
205
|
+
minx = min(px_ - k.w / 2 for k, px_, _ in pts)
|
|
206
|
+
miny = min(py - k.h / 2 for k, _, py in pts)
|
|
207
|
+
for k, cx, cy in pts:
|
|
208
|
+
k.x, k.y = cx - k.w / 2 - minx, cy - k.h / 2 - miny
|
|
209
|
+
return (max(k.x + k.w for k, _, _ in pts), max(k.y + k.h for k, _, _ in pts))
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class LayeredLayout(Layout):
|
|
213
|
+
"""Sugiyama-style: ranking, (optional) crossing reduction, balancing."""
|
|
214
|
+
|
|
215
|
+
def run(self):
|
|
216
|
+
g = self.g
|
|
217
|
+
d = opt(g, "dir", "TB").upper()
|
|
218
|
+
horizontal = d in ("LR", "RL")
|
|
219
|
+
rank_gap = px(opt(g, "rank-gap", "56"))
|
|
220
|
+
node_gap = px(opt(g, "node-gap", "28"))
|
|
221
|
+
balance = opt(g, "balance", "median")
|
|
222
|
+
order_mode = opt(g, "order", "auto")
|
|
223
|
+
kids = self.kids
|
|
224
|
+
idx = {k.id: i for i, k in enumerate(kids)}
|
|
225
|
+
edges = [(a.id, b.id, float(e.attrs.get("weight", 1))) for a, b, e in local_edges(g, self.doc)]
|
|
226
|
+
# where inside a child box does the real endpoint sit (cross axis, rel. to centre)?
|
|
227
|
+
# lets a node line up with the actual node inside a group, not the group centre
|
|
228
|
+
self.portoff = {}
|
|
229
|
+
for a, b, e in local_edges(g, self.doc):
|
|
230
|
+
self.portoff[(a.id, b.id)] = self._inner_off(self.doc.items[e.src], a, horizontal)
|
|
231
|
+
self.portoff[(b.id, a.id)] = self._inner_off(self.doc.items[e.dst], b, horizontal)
|
|
232
|
+
|
|
233
|
+
# 1. break cycles with DFS in source order
|
|
234
|
+
succ = defaultdict(list)
|
|
235
|
+
for a, b, w in edges:
|
|
236
|
+
succ[a].append((b, w))
|
|
237
|
+
state, dag = {}, []
|
|
238
|
+
|
|
239
|
+
def dfs(u):
|
|
240
|
+
state[u] = 1
|
|
241
|
+
for v, w in succ[u]:
|
|
242
|
+
if state.get(v) == 1:
|
|
243
|
+
dag.append((v, u, w)) # reverse back edge
|
|
244
|
+
else:
|
|
245
|
+
dag.append((u, v, w))
|
|
246
|
+
if v not in state:
|
|
247
|
+
dfs(v)
|
|
248
|
+
state[u] = 2
|
|
249
|
+
|
|
250
|
+
for k in kids:
|
|
251
|
+
if k.id not in state:
|
|
252
|
+
dfs(k.id)
|
|
253
|
+
dag = list(dict.fromkeys(dag))
|
|
254
|
+
|
|
255
|
+
# 2. longest-path ranking, honour rank= pins
|
|
256
|
+
rank = {k.id: int(k.attrs["rank"]) for k in kids if "rank" in k.attrs}
|
|
257
|
+
preds = defaultdict(list)
|
|
258
|
+
for a, b, _ in dag:
|
|
259
|
+
preds[b].append(a)
|
|
260
|
+
def get_rank(u, seen=()):
|
|
261
|
+
if u in rank:
|
|
262
|
+
return rank[u]
|
|
263
|
+
r = 0
|
|
264
|
+
for p in preds[u]:
|
|
265
|
+
if p not in seen:
|
|
266
|
+
r = max(r, get_rank(p, seen + (u,)) + 1)
|
|
267
|
+
rank[u] = r
|
|
268
|
+
return r
|
|
269
|
+
for k in kids:
|
|
270
|
+
get_rank(k.id)
|
|
271
|
+
# pull sources down next to their successors (avoids long first edges)
|
|
272
|
+
succ_dag = defaultdict(list)
|
|
273
|
+
for a, b, _ in dag:
|
|
274
|
+
succ_dag[a].append(b)
|
|
275
|
+
for k in kids:
|
|
276
|
+
if not preds[k.id] and succ_dag[k.id] and "rank" not in k.attrs:
|
|
277
|
+
rank[k.id] = min(rank[s] for s in succ_dag[k.id]) - 1
|
|
278
|
+
|
|
279
|
+
# 3. virtual nodes for long edges so they bend around ranks
|
|
280
|
+
layers: dict[int, list[str]] = defaultdict(list)
|
|
281
|
+
for k in kids:
|
|
282
|
+
layers[rank[k.id]].append(k.id)
|
|
283
|
+
size = {k.id: (k.w, k.h) for k in kids}
|
|
284
|
+
adj = [] # (upper, lower, weight)
|
|
285
|
+
vcount = 0
|
|
286
|
+
self.virtual_chains = {}
|
|
287
|
+
for a, b, w in dag:
|
|
288
|
+
ra, rb = rank[a], rank[b]
|
|
289
|
+
if rb - ra <= 1:
|
|
290
|
+
adj.append((a, b, w))
|
|
291
|
+
continue
|
|
292
|
+
prev = a
|
|
293
|
+
chain = []
|
|
294
|
+
for r in range(ra + 1, rb):
|
|
295
|
+
vid = f"__v{vcount}"
|
|
296
|
+
vcount += 1
|
|
297
|
+
layers[r].append(vid)
|
|
298
|
+
size[vid] = (0.0, 0.0)
|
|
299
|
+
rank[vid] = r
|
|
300
|
+
adj.append((prev, vid, w))
|
|
301
|
+
if prev == a:
|
|
302
|
+
self.portoff[(a, vid)] = self.portoff.get((a, b), self.portoff.get((a, b), 0.0))
|
|
303
|
+
chain.append(vid)
|
|
304
|
+
prev = vid
|
|
305
|
+
adj.append((prev, b, w))
|
|
306
|
+
self.portoff[(b, prev)] = self.portoff.get((b, a), 0.0)
|
|
307
|
+
self.virtual_chains[(a, b)] = chain
|
|
308
|
+
lo, hi = min(layers), max(layers)
|
|
309
|
+
order = [layers.get(r, []) for r in range(lo, hi + 1)]
|
|
310
|
+
up, down = defaultdict(list), defaultdict(list)
|
|
311
|
+
for a, b, w in adj:
|
|
312
|
+
down[a].append((b, w))
|
|
313
|
+
up[b].append((a, w))
|
|
314
|
+
|
|
315
|
+
# 4. crossing reduction (barycenter sweeps) unless order=source
|
|
316
|
+
if order_mode != "source":
|
|
317
|
+
def pos_of(layer):
|
|
318
|
+
return {u: i for i, u in enumerate(layer)}
|
|
319
|
+
def crossings(ordr):
|
|
320
|
+
c = 0
|
|
321
|
+
for i in range(len(ordr) - 1):
|
|
322
|
+
p2 = pos_of(ordr[i + 1])
|
|
323
|
+
es = [(j, p2[v]) for j, u in enumerate(ordr[i]) for v, _ in down[u] if v in p2]
|
|
324
|
+
c += sum(1 for x in range(len(es)) for y in range(x + 1, len(es))
|
|
325
|
+
if (es[x][0] - es[y][0]) * (es[x][1] - es[y][1]) < 0)
|
|
326
|
+
return c
|
|
327
|
+
best, best_c = [list(l) for l in order], crossings(order)
|
|
328
|
+
for it in range(8):
|
|
329
|
+
rng = range(1, len(order)) if it % 2 == 0 else range(len(order) - 2, -1, -1)
|
|
330
|
+
nb = up if it % 2 == 0 else down
|
|
331
|
+
for i in rng:
|
|
332
|
+
ref = pos_of(order[i - 1] if it % 2 == 0 else order[i + 1])
|
|
333
|
+
def bary(u, i=i, ref=ref):
|
|
334
|
+
ns = [ref[v] for v, _ in nb[u] if v in ref]
|
|
335
|
+
return sum(ns) / len(ns) if ns else order[i].index(u)
|
|
336
|
+
order[i] = sorted(order[i], key=lambda u: (bary(u), idx.get(u, 1e9)))
|
|
337
|
+
c = crossings(order)
|
|
338
|
+
if c < best_c:
|
|
339
|
+
best, best_c = [list(l) for l in order], c
|
|
340
|
+
order = best
|
|
341
|
+
|
|
342
|
+
# 5. cross-axis coordinates
|
|
343
|
+
def csz(u):
|
|
344
|
+
w, h = size[u]
|
|
345
|
+
return h if horizontal else w
|
|
346
|
+
def msz(u):
|
|
347
|
+
w, h = size[u]
|
|
348
|
+
return w if horizontal else h
|
|
349
|
+
vgap = node_gap / 2
|
|
350
|
+
pos: dict[str, float] = {}
|
|
351
|
+
widths = []
|
|
352
|
+
for layer in order:
|
|
353
|
+
x = 0.0
|
|
354
|
+
for u in layer:
|
|
355
|
+
pos[u] = x + csz(u) / 2
|
|
356
|
+
x += csz(u) + (vgap if u.startswith("__v") else node_gap)
|
|
357
|
+
widths.append(x - node_gap if layer else 0)
|
|
358
|
+
maxw = max(widths) if widths else 0
|
|
359
|
+
if balance in ("center", "median"):
|
|
360
|
+
for layer, w in zip(order, widths):
|
|
361
|
+
shift = (maxw - w) / 2
|
|
362
|
+
for u in layer:
|
|
363
|
+
pos[u] += shift
|
|
364
|
+
elif balance == "end":
|
|
365
|
+
for layer, w in zip(order, widths):
|
|
366
|
+
for u in layer:
|
|
367
|
+
pos[u] += maxw - w
|
|
368
|
+
if balance == "median":
|
|
369
|
+
pos = self._median_pull(order, pos, up, down, csz, node_gap, vgap)
|
|
370
|
+
|
|
371
|
+
# 6. main-axis coordinates (router may ask for wider gaps: g.extra_gap[rank])
|
|
372
|
+
rank_thick = [max((msz(u) for u in layer), default=0) for layer in order]
|
|
373
|
+
extra = dict(getattr(g, "extra_gap", {}))
|
|
374
|
+
# labelled edges need a run long enough to carry the label beside the bus
|
|
375
|
+
for a, b, e in local_edges(g, self.doc):
|
|
376
|
+
if e.label and a.id in rank and b.id in rank:
|
|
377
|
+
lh = px(e.style.get("font-size"), 11) * 1.25 * (e.label.count("\n") + 1) + 10
|
|
378
|
+
r = min(rank[a.id], rank[b.id]) - lo
|
|
379
|
+
extra[r] = max(extra.get(r, 0.0), lh)
|
|
380
|
+
main = 0.0
|
|
381
|
+
main_pos = []
|
|
382
|
+
for ri, t in enumerate(rank_thick):
|
|
383
|
+
main_pos.append(main)
|
|
384
|
+
main += t + rank_gap + extra.get(ri, 0.0)
|
|
385
|
+
total_main = main - rank_gap - extra.get(len(rank_thick) - 1, 0.0)
|
|
386
|
+
self.rank_index = {u: ri for ri, layer in enumerate(order) for u in layer if not u.startswith("__v")}
|
|
387
|
+
minc = min((pos[u] - csz(u) / 2 for layer in order for u in layer), default=0)
|
|
388
|
+
self.virtual_pos = {}
|
|
389
|
+
for ri, layer in enumerate(order):
|
|
390
|
+
for u in layer:
|
|
391
|
+
c = pos[u] - minc
|
|
392
|
+
m = main_pos[ri] + rank_thick[ri] / 2
|
|
393
|
+
if d in ("BT", "RL"):
|
|
394
|
+
m = total_main - m
|
|
395
|
+
if u.startswith("__v"):
|
|
396
|
+
self.virtual_pos[u] = (m, c) if horizontal else (c, m)
|
|
397
|
+
continue
|
|
398
|
+
k = kids[idx[u]]
|
|
399
|
+
if horizontal:
|
|
400
|
+
k.x, k.y = m - k.w / 2, c - k.h / 2
|
|
401
|
+
else:
|
|
402
|
+
k.x, k.y = c - k.w / 2, m - k.h / 2
|
|
403
|
+
cross_total = max((pos[u] - minc + csz(u) / 2 for layer in order for u in layer), default=0)
|
|
404
|
+
self._store_waypoints(horizontal)
|
|
405
|
+
return (total_main, cross_total) if horizontal else (cross_total, total_main)
|
|
406
|
+
|
|
407
|
+
@staticmethod
|
|
408
|
+
def _inner_off(item, child, horizontal):
|
|
409
|
+
"""Cross-axis offset of `item`'s centre from the centre of `child` (its ancestor-or-self)."""
|
|
410
|
+
if item is child:
|
|
411
|
+
return 0.0
|
|
412
|
+
c = (item.y + item.h / 2) if horizontal else (item.x + item.w / 2)
|
|
413
|
+
cur = item.parent
|
|
414
|
+
while cur is not None and cur is not child:
|
|
415
|
+
c += (cur.oy + cur.y) if horizontal else (cur.ox + cur.x)
|
|
416
|
+
cur = cur.parent
|
|
417
|
+
if cur is None:
|
|
418
|
+
return 0.0
|
|
419
|
+
c += child.oy if horizontal else child.ox
|
|
420
|
+
return c - ((child.h if horizontal else child.w) / 2)
|
|
421
|
+
|
|
422
|
+
def _median_pull(self, order, pos, up, down, csz, node_gap, vgap):
|
|
423
|
+
def gap(a, b):
|
|
424
|
+
return (vgap if a.startswith("__v") or b.startswith("__v") else node_gap)
|
|
425
|
+
for it in range(12):
|
|
426
|
+
for li in (range(len(order)) if it % 2 == 0 else range(len(order) - 1, -1, -1)):
|
|
427
|
+
layer = order[li]
|
|
428
|
+
want = {}
|
|
429
|
+
po = self.portoff
|
|
430
|
+
for u in layer:
|
|
431
|
+
ns = [(pos[v] + po.get((v, u), 0.0) - po.get((u, v), 0.0), w) for v, w in up[u] + down[u]]
|
|
432
|
+
if ns:
|
|
433
|
+
ns.sort()
|
|
434
|
+
tot = sum(w for _, w in ns)
|
|
435
|
+
acc, med = 0.0, ns[0][0]
|
|
436
|
+
for p, w in ns:
|
|
437
|
+
acc += w
|
|
438
|
+
if acc >= tot / 2:
|
|
439
|
+
med = p
|
|
440
|
+
break
|
|
441
|
+
if len(ns) % 2 == 0 and all(w == ns[0][1] for _, w in ns):
|
|
442
|
+
med = (ns[len(ns) // 2 - 1][0] + ns[len(ns) // 2][0]) / 2
|
|
443
|
+
want[u] = med
|
|
444
|
+
else:
|
|
445
|
+
want[u] = pos[u]
|
|
446
|
+
# place in order keeping min separation, then fix overlaps right-to-left
|
|
447
|
+
new = [want[u] for u in layer]
|
|
448
|
+
for i in range(1, len(layer)):
|
|
449
|
+
mn = new[i - 1] + (csz(layer[i - 1]) + csz(layer[i])) / 2 + gap(layer[i - 1], layer[i])
|
|
450
|
+
new[i] = max(new[i], mn)
|
|
451
|
+
# shift the packed block by the median residual (L1 optimum): when a parent
|
|
452
|
+
# has an odd number of children, the middle child lands exactly under it
|
|
453
|
+
if layer:
|
|
454
|
+
res = sorted(want[u] - n for n, u in zip(new, layer))
|
|
455
|
+
m = len(res) // 2
|
|
456
|
+
shift = res[m] if len(res) % 2 else (res[m - 1] + res[m]) / 2
|
|
457
|
+
new = [n + shift for n in new]
|
|
458
|
+
for u, n in zip(layer, new):
|
|
459
|
+
pos[u] = n
|
|
460
|
+
return pos
|
|
461
|
+
|
|
462
|
+
def _store_waypoints(self, horizontal):
|
|
463
|
+
self.g.attrs.setdefault("__dir__", "LR" if horizontal else "TB")
|
|
464
|
+
self.waypoints = {}
|
|
465
|
+
for (a, b), chain in self.virtual_chains.items():
|
|
466
|
+
self.waypoints[(a, b)] = [self.virtual_pos[v] for v in chain]
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
LAYOUTS = {"layered": LayeredLayout, "grid": GridLayout, "row": LineLayout,
|
|
470
|
+
"column": LineLayout, "radial": RadialLayout, "fixed": FixedLayout}
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def layout_group(g: Group, doc: Document) -> None:
|
|
474
|
+
for c in g.children:
|
|
475
|
+
if isinstance(c, Group):
|
|
476
|
+
layout_group(c, doc)
|
|
477
|
+
else:
|
|
478
|
+
size_node(c, doc)
|
|
479
|
+
if "layout" not in g.attrs:
|
|
480
|
+
g.attrs["layout"] = "layered" if local_edges(g, doc) or g.kind == "graph" else "row"
|
|
481
|
+
pt, pr, pb, pl = box4(g.style.get("padding"), 14)
|
|
482
|
+
if not g.children:
|
|
483
|
+
cw, ch = 40.0, 20.0
|
|
484
|
+
lay = None
|
|
485
|
+
else:
|
|
486
|
+
lay = LAYOUTS[g.attrs["layout"]](g, doc)
|
|
487
|
+
cw, ch = lay.run()
|
|
488
|
+
g.label_h = 0.0
|
|
489
|
+
lw = 0.0
|
|
490
|
+
if g.label and g.kind == "group":
|
|
491
|
+
fs = px(g.style.get("font-size"), 12)
|
|
492
|
+
lw, g.label_h, _ = measure(g.label, g.style.get("font-family", "sans-serif"), fs,
|
|
493
|
+
g.style.get("font-weight", "normal") in ("bold", "700"))
|
|
494
|
+
g.label_h += 6
|
|
495
|
+
inner_w = max(cw, lw) + getattr(g, "extra_w", 0.0) # extra_w: room requested for the title
|
|
496
|
+
top = g.style.get("label-position", "top") != "bottom"
|
|
497
|
+
g.ox = pl + (inner_w - cw) / 2
|
|
498
|
+
g.oy = pt + (g.label_h if top else 0)
|
|
499
|
+
g.w = inner_w + pl + pr
|
|
500
|
+
g.h = ch + pt + pb + g.label_h
|
|
501
|
+
g.layout_obj = lay
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def absolutize(g: Group, ox: float, oy: float) -> None:
|
|
505
|
+
g.ax, g.ay = ox, oy
|
|
506
|
+
for c in g.children:
|
|
507
|
+
c.ax, c.ay = ox + g.ox + c.x, oy + g.oy + c.y
|
|
508
|
+
if isinstance(c, Group):
|
|
509
|
+
absolutize(c, c.ax, c.ay)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def layout(doc: Document) -> None:
|
|
513
|
+
root = doc.root
|
|
514
|
+
for e in doc.edges: # layout may run again after the router widens gaps
|
|
515
|
+
if e.wp_owner is not None:
|
|
516
|
+
e.waypoints, e.wp_owner = [], None
|
|
517
|
+
layout_group(root, doc)
|
|
518
|
+
absolutize(root, 0, 0)
|
|
519
|
+
# translate virtual waypoints into absolute coords and attach to edges
|
|
520
|
+
for g in [root] + [i for i in doc.walk() if isinstance(i, Group)]:
|
|
521
|
+
lay = getattr(g, "layout_obj", None)
|
|
522
|
+
wps = getattr(lay, "waypoints", None)
|
|
523
|
+
if not wps:
|
|
524
|
+
continue
|
|
525
|
+
for e in doc.edges:
|
|
526
|
+
a = direct_child(doc.items[e.src], g)
|
|
527
|
+
b = direct_child(doc.items[e.dst], g)
|
|
528
|
+
if a is None or b is None:
|
|
529
|
+
continue
|
|
530
|
+
for key, rev in (((a.id, b.id), False), ((b.id, a.id), True)):
|
|
531
|
+
if key in wps and not e.waypoints:
|
|
532
|
+
pts = [(g.ax + g.ox + x, g.ay + g.oy + y) for x, y in wps[key]]
|
|
533
|
+
e.waypoints = pts[::-1] if rev else pts
|
|
534
|
+
e.wp_owner = g
|
predictygraph/model.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 The predictygraph Authors
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
"""Document model: graph, groups, nodes, edges."""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Item:
|
|
13
|
+
id: str
|
|
14
|
+
label: str | None = None
|
|
15
|
+
classes: list[str] = field(default_factory=list)
|
|
16
|
+
attrs: dict[str, str] = field(default_factory=dict)
|
|
17
|
+
parent: "Group | None" = None
|
|
18
|
+
style: dict[str, str] = field(default_factory=dict)
|
|
19
|
+
# layout results (relative to parent content origin, then absolute)
|
|
20
|
+
w: float = 0.0
|
|
21
|
+
h: float = 0.0
|
|
22
|
+
x: float = 0.0
|
|
23
|
+
y: float = 0.0
|
|
24
|
+
ax: float = 0.0
|
|
25
|
+
ay: float = 0.0
|
|
26
|
+
kind: str = "node"
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def cx(self) -> float:
|
|
30
|
+
return self.ax + self.w / 2
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def cy(self) -> float:
|
|
34
|
+
return self.ay + self.h / 2
|
|
35
|
+
|
|
36
|
+
def ancestors(self) -> list["Group"]:
|
|
37
|
+
out, p = [], self.parent
|
|
38
|
+
while p is not None:
|
|
39
|
+
out.append(p)
|
|
40
|
+
p = p.parent
|
|
41
|
+
return out
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class Node(Item):
|
|
46
|
+
implicit: bool = False
|
|
47
|
+
image: dict | None = None
|
|
48
|
+
text_lines: list[str] = field(default_factory=list)
|
|
49
|
+
text_w: float = 0.0
|
|
50
|
+
text_h: float = 0.0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class Group(Item):
|
|
55
|
+
kind: str = "group"
|
|
56
|
+
children: list[Item] = field(default_factory=list)
|
|
57
|
+
defaults: dict[str, dict[str, str]] = field(default_factory=dict)
|
|
58
|
+
# content box offset inside the group box
|
|
59
|
+
ox: float = 0.0
|
|
60
|
+
oy: float = 0.0
|
|
61
|
+
label_h: float = 0.0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class Edge:
|
|
66
|
+
src: str
|
|
67
|
+
dst: str
|
|
68
|
+
arrow: str # none|end|start|both
|
|
69
|
+
body: str # solid|thick|dashed
|
|
70
|
+
label: str | None = None
|
|
71
|
+
classes: list[str] = field(default_factory=list)
|
|
72
|
+
attrs: dict[str, str] = field(default_factory=dict)
|
|
73
|
+
scope: Group | None = None
|
|
74
|
+
style: dict[str, str] = field(default_factory=dict)
|
|
75
|
+
label_style: dict[str, str] = field(default_factory=dict)
|
|
76
|
+
index: int = 0
|
|
77
|
+
waypoints: list[tuple[float, float]] = field(default_factory=list)
|
|
78
|
+
wp_owner: Group | None = None
|
|
79
|
+
dir_hint: str | None = None
|
|
80
|
+
kind: str = "edge"
|
|
81
|
+
id: str = ""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class Document:
|
|
86
|
+
root: Group
|
|
87
|
+
edges: list[Edge] = field(default_factory=list)
|
|
88
|
+
stylesheets: list[tuple[str, str]] = field(default_factory=list) # (css text, base dir)
|
|
89
|
+
items: dict[str, Item] = field(default_factory=dict)
|
|
90
|
+
base_dir: str = "."
|
|
91
|
+
allow_outside: bool = False # let image=/css "..." read files outside base_dir
|
|
92
|
+
|
|
93
|
+
def walk(self, g: Group | None = None):
|
|
94
|
+
g = g or self.root
|
|
95
|
+
for c in g.children:
|
|
96
|
+
yield c
|
|
97
|
+
if isinstance(c, Group):
|
|
98
|
+
yield from self.walk(c)
|