databricks-explore 0.5.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.
- databricks_explore/__init__.py +3 -0
- databricks_explore/__main__.py +6 -0
- databricks_explore/app.py +673 -0
- databricks_explore/canvases.py +333 -0
- databricks_explore/cli.py +80 -0
- databricks_explore/concepts.py +325 -0
- databricks_explore/dbx.py +537 -0
- databricks_explore/demo.py +653 -0
- databricks_explore/demo_history.py +157 -0
- databricks_explore/genie.py +281 -0
- databricks_explore/layout.py +1764 -0
- databricks_explore/mcp_server/__init__.py +93 -0
- databricks_explore/mcp_server/auth.py +95 -0
- databricks_explore/mcp_server/rpc.py +88 -0
- databricks_explore/mcp_server/schema.py +68 -0
- databricks_explore/mcp_server/sqlguard.py +59 -0
- databricks_explore/mcp_server/tooling.py +74 -0
- databricks_explore/mcp_server/tools_canvas.py +430 -0
- databricks_explore/mcp_server/tools_data.py +472 -0
- databricks_explore/mining.py +433 -0
- databricks_explore/ontology.py +508 -0
- databricks_explore/relationships.py +987 -0
- databricks_explore/sqltools.py +525 -0
- databricks_explore/web_dist/THIRD-PARTY-NOTICES.txt +5853 -0
- databricks_explore/web_dist/assets/index-6I1cPS7i.css +1 -0
- databricks_explore/web_dist/assets/index-CR9j5CJG.js +330 -0
- databricks_explore/web_dist/index.html +13 -0
- databricks_explore-0.5.0.dist-info/METADATA +207 -0
- databricks_explore-0.5.0.dist-info/RECORD +32 -0
- databricks_explore-0.5.0.dist-info/WHEEL +4 -0
- databricks_explore-0.5.0.dist-info/entry_points.txt +2 -0
- databricks_explore-0.5.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1764 @@
|
|
|
1
|
+
"""Layout doctor, tidy, arrange, describe and render for saved canvases (format version 2). Pure: no Flask, no SDK.
|
|
2
|
+
|
|
3
|
+
A canvas here is the saved object: `windows` (each with `position` {x, y} and `size` {w, h}; flat `x`, `y`, `w`, `h`
|
|
4
|
+
keys are read too) and top-level `edges`. Frames hold other nodes by geometry, and locked nodes never move.
|
|
5
|
+
Every function returns new objects and leaves its input alone. Nothing reads a clock or a random source, so the
|
|
6
|
+
same canvas always gives the same answer.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import copy
|
|
11
|
+
import io
|
|
12
|
+
import math
|
|
13
|
+
from typing import Any, Iterable, Optional
|
|
14
|
+
|
|
15
|
+
RULES = ("fit_labels", "frames", "overlaps", "align", "spacing", "grid", "handles", "place_labels")
|
|
16
|
+
ARRANGE_MODES = ("grid", "row", "column", "layered", "place")
|
|
17
|
+
PLACE_RELATIONS = ("right_of", "left_of", "below", "above", "inside")
|
|
18
|
+
|
|
19
|
+
OVERLAP_TOL = 2.0 # px of intersection on both axes that still counts as touching, not overlapping
|
|
20
|
+
NEAR_MIN, NEAR_MAX = 1.0, 6.0 # edges this far apart are a near-miss alignment
|
|
21
|
+
NEIGHBOR_DIST = 300.0 # nodes farther apart than this are not compared for alignment
|
|
22
|
+
MIN_GAP = 16 # smallest clearance left when an overlap is pushed apart
|
|
23
|
+
LABEL_GAP_PAD = 8 # px of slack a connector label needs beyond its own box on the gap it sits in
|
|
24
|
+
LABELED_MIN_GAP = 40 # smallest clearance between two nodes joined by a labeled connector
|
|
25
|
+
FRAME_PAD = 12 # padding when a frame grows to hold a member
|
|
26
|
+
COLLAPSED_H = 36.0 # height of a collapsed window (its title bar)
|
|
27
|
+
STEP_OFFSET = 20.0 # how far a step connector leaves a node before turning
|
|
28
|
+
DIR = {"t": (0, -1), "r": (1, 0), "b": (0, 1), "l": (-1, 0)}
|
|
29
|
+
SKETCH = ("shape", "text", "sticky", "frame", "placeholder")
|
|
30
|
+
MAX_FIT_W = 320 # a sketch label wider than this wraps (and the node grows taller) instead of widening further
|
|
31
|
+
LABEL_CHAR_W = 6.7 # connector labels: 12 px text, estimated character width
|
|
32
|
+
LABEL_LINE_H = 15.6 # one line of a connector label
|
|
33
|
+
LABEL_PAD = (14.0, 6.0) # horizontal and vertical padding plus border of a connector label box
|
|
34
|
+
LABEL_STEP = 4 # px a connector label is nudged at a time along its segment
|
|
35
|
+
SHARE_TOL = 1.0 # px: two connector segments closer than this and parallel run along each other
|
|
36
|
+
|
|
37
|
+
PENALTY = { # score points per issue: (each, cap)
|
|
38
|
+
"overlap": (8, 40), "edge_through_node": (6, 24), "bad_handle_side": (3, 15), "outside_frame": (4, 16),
|
|
39
|
+
"near_alignment": (1.5, 12), "uneven_spacing": (3, 12), "label_overflow": (2, 10), "edge_crossings": (1.5, 15),
|
|
40
|
+
"far_away": (2, 6), "off_grid": (0.5, 5), "label_collision": (2, 10),
|
|
41
|
+
}
|
|
42
|
+
SEVERITY = {
|
|
43
|
+
"overlap": "error", "edge_through_node": "error", "bad_handle_side": "warning", "outside_frame": "warning",
|
|
44
|
+
"near_alignment": "warning", "uneven_spacing": "warning", "label_overflow": "warning",
|
|
45
|
+
"edge_crossings": "warning", "far_away": "info", "off_grid": "info", "label_collision": "warning",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class LayoutError(ValueError):
|
|
50
|
+
"""The request cannot be laid out (unknown id, bad mode or option). The route returns it as HTTP 400."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------- geometry
|
|
54
|
+
|
|
55
|
+
def _num(v: Any, default: float = 0.0) -> float:
|
|
56
|
+
return float(v) if isinstance(v, (int, float)) and not isinstance(v, bool) else default
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _nodes(canvas: dict) -> list[dict]:
|
|
60
|
+
return [n for n in canvas.get("windows", []) if isinstance(n, dict) and "id" in n]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _edges(canvas: dict) -> list[dict]:
|
|
64
|
+
return [e for e in canvas.get("edges", []) or [] if isinstance(e, dict) and "id" in e]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _x(n: dict) -> float:
|
|
68
|
+
p = n.get("position")
|
|
69
|
+
return _num(p.get("x")) if isinstance(p, dict) else _num(n.get("x"))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _y(n: dict) -> float:
|
|
73
|
+
p = n.get("position")
|
|
74
|
+
return _num(p.get("y")) if isinstance(p, dict) else _num(n.get("y"))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _w(n: dict) -> float:
|
|
78
|
+
s = n.get("size")
|
|
79
|
+
return max(_num(s.get("w")) if isinstance(s, dict) else _num(n.get("w")), 1.0)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _h(n: dict) -> float:
|
|
83
|
+
if n.get("collapsed") is True and n.get("type") not in SKETCH:
|
|
84
|
+
return COLLAPSED_H
|
|
85
|
+
s = n.get("size")
|
|
86
|
+
return max(_num(s.get("h")) if isinstance(s, dict) else _num(n.get("h")), 1.0)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _clean(v: float):
|
|
90
|
+
v = round(v)
|
|
91
|
+
return int(v)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _put(n: dict, x: Optional[float] = None, y: Optional[float] = None,
|
|
95
|
+
w: Optional[float] = None, h: Optional[float] = None) -> None:
|
|
96
|
+
"""Writes whole-pixel geometry, into position/size when the node has them and flat keys otherwise."""
|
|
97
|
+
for key, v, outer, inner in (("x", x, "position", "x"), ("y", y, "position", "y"),
|
|
98
|
+
("w", w, "size", "w"), ("h", h, "size", "h")):
|
|
99
|
+
if v is None:
|
|
100
|
+
continue
|
|
101
|
+
if isinstance(n.get(outer), dict):
|
|
102
|
+
n[outer][inner] = _clean(v)
|
|
103
|
+
else:
|
|
104
|
+
n[key] = _clean(v)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _box(n: dict) -> tuple[float, float, float, float]:
|
|
108
|
+
x, y = _x(n), _y(n)
|
|
109
|
+
return (x, y, x + _w(n), y + _h(n))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _area(n: dict) -> float:
|
|
113
|
+
return _w(n) * _h(n)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _inter(a: tuple, b: tuple) -> tuple[float, float]:
|
|
117
|
+
"""Width and height of the intersection of two boxes (negative when apart)."""
|
|
118
|
+
return (min(a[2], b[2]) - max(a[0], b[0]), min(a[3], b[3]) - max(a[1], b[1]))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _gap_dist(a: tuple, b: tuple) -> float:
|
|
122
|
+
gx, gy = max(0.0, max(a[0], b[0]) - min(a[2], b[2])), max(0.0, max(a[1], b[1]) - min(a[3], b[3]))
|
|
123
|
+
return math.hypot(gx, gy)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _order_key(n: dict):
|
|
127
|
+
return (_y(n), _x(n), str(n["id"]))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _grid_of(canvas: dict, grid: Optional[float]) -> Optional[float]:
|
|
131
|
+
g = grid if grid is not None else canvas.get("grid")
|
|
132
|
+
return float(g) if isinstance(g, (int, float)) and not isinstance(g, bool) and g > 0 else None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _parents(nodes: list[dict]) -> dict[str, str]:
|
|
136
|
+
"""Frame membership by geometry: a node belongs to the smallest larger frame holding at least half of it."""
|
|
137
|
+
frames = [n for n in nodes if n.get("type") == "frame"]
|
|
138
|
+
out: dict[str, str] = {}
|
|
139
|
+
for n in nodes:
|
|
140
|
+
a, nb = _area(n), _box(n)
|
|
141
|
+
best = None
|
|
142
|
+
for f in frames:
|
|
143
|
+
if f is n or _area(f) <= a:
|
|
144
|
+
continue
|
|
145
|
+
ix, iy = _inter(nb, _box(f))
|
|
146
|
+
if ix > 0 and iy > 0 and ix * iy >= 0.5 * a and (best is None or _area(f) < _area(best)):
|
|
147
|
+
best = f
|
|
148
|
+
if best is not None:
|
|
149
|
+
out[n["id"]] = best["id"]
|
|
150
|
+
return out
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _ancestors(nid: str, parent: dict[str, str]) -> set[str]:
|
|
154
|
+
out: set[str] = set()
|
|
155
|
+
while nid in parent and parent[nid] not in out:
|
|
156
|
+
nid = parent[nid]
|
|
157
|
+
out.add(nid)
|
|
158
|
+
return out
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _descendants(nid: str, parent: dict[str, str]) -> list[str]:
|
|
162
|
+
return [k for k in parent if nid in _ancestors(k, parent)]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _overlap_pairs(nodes: list[dict], parent: dict[str, str], tol: float = OVERLAP_TOL) -> list[tuple]:
|
|
166
|
+
"""(a, b, width, height) for each pair of unrelated nodes whose bodies intersect by more than `tol` on both axes."""
|
|
167
|
+
out = []
|
|
168
|
+
anc = {n["id"]: _ancestors(n["id"], parent) for n in nodes}
|
|
169
|
+
boxes = [_box(n) for n in nodes]
|
|
170
|
+
for i in range(len(nodes)):
|
|
171
|
+
for j in range(i + 1, len(nodes)):
|
|
172
|
+
a, b = nodes[i], nodes[j]
|
|
173
|
+
if a["id"] in anc[b["id"]] or b["id"] in anc[a["id"]]:
|
|
174
|
+
continue
|
|
175
|
+
ox, oy = _inter(boxes[i], boxes[j])
|
|
176
|
+
if ox > tol and oy > tol:
|
|
177
|
+
out.append((a, b, ox, oy))
|
|
178
|
+
return out
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# ---------------------------------------------------------------- connectors
|
|
182
|
+
|
|
183
|
+
def _facing(a: tuple, b: tuple) -> str:
|
|
184
|
+
dx = (b[0] + b[2]) / 2 - (a[0] + a[2]) / 2
|
|
185
|
+
dy = (b[1] + b[3]) / 2 - (a[1] + a[3]) / 2
|
|
186
|
+
if abs(dx) >= abs(dy):
|
|
187
|
+
return "r" if dx >= 0 else "l"
|
|
188
|
+
return "b" if dy >= 0 else "t"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _faces_away(n: tuple, side: str, m: tuple) -> bool:
|
|
192
|
+
"""True when box m lies wholly on the side of n opposite the handle `side`."""
|
|
193
|
+
if side == "l":
|
|
194
|
+
return m[0] >= n[2]
|
|
195
|
+
if side == "r":
|
|
196
|
+
return m[2] <= n[0]
|
|
197
|
+
if side == "t":
|
|
198
|
+
return m[1] >= n[3]
|
|
199
|
+
return m[3] <= n[1]
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _side_point(box: tuple, side: str) -> tuple[float, float]:
|
|
203
|
+
x1, y1, x2, y2 = box
|
|
204
|
+
return {"t": ((x1 + x2) / 2, y1), "r": (x2, (y1 + y2) / 2), "b": ((x1 + x2) / 2, y2), "l": (x1, (y1 + y2) / 2)}[side]
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _handles(e: dict, a: tuple, b: tuple) -> tuple[str, str]:
|
|
208
|
+
return (e.get("source_handle") or _facing(a, b), e.get("target_handle") or _facing(b, a))
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _route(e: dict, a: tuple, b: tuple) -> list[tuple[float, float]]:
|
|
212
|
+
"""The polyline a connector is drawn along. Step routing is an approximation of the canvas's own."""
|
|
213
|
+
sh, th = _handles(e, a, b)
|
|
214
|
+
p, q = _side_point(a, sh), _side_point(b, th)
|
|
215
|
+
if e.get("line", "step") != "step":
|
|
216
|
+
return [p, q]
|
|
217
|
+
horiz_s, horiz_t = sh in "lr", th in "lr"
|
|
218
|
+
if horiz_s and horiz_t and ((sh == "r" and th == "l" and q[0] >= p[0]) or (sh == "l" and th == "r" and q[0] <= p[0])):
|
|
219
|
+
mx = (p[0] + q[0]) / 2
|
|
220
|
+
return [p, (mx, p[1]), (mx, q[1]), q]
|
|
221
|
+
if not horiz_s and not horiz_t and ((sh == "b" and th == "t" and q[1] >= p[1]) or (sh == "t" and th == "b" and q[1] <= p[1])):
|
|
222
|
+
my = (p[1] + q[1]) / 2
|
|
223
|
+
return [p, (p[0], my), (q[0], my), q]
|
|
224
|
+
p1 = (p[0] + DIR[sh][0] * STEP_OFFSET, p[1] + DIR[sh][1] * STEP_OFFSET)
|
|
225
|
+
q1 = (q[0] + DIR[th][0] * STEP_OFFSET, q[1] + DIR[th][1] * STEP_OFFSET)
|
|
226
|
+
if horiz_s and not horiz_t:
|
|
227
|
+
mid = [(q1[0], p1[1])]
|
|
228
|
+
elif not horiz_s and horiz_t:
|
|
229
|
+
mid = [(p1[0], q1[1])]
|
|
230
|
+
elif horiz_s:
|
|
231
|
+
xo = max(p1[0], q1[0]) if sh == "r" and th == "r" else min(p1[0], q1[0]) if sh == "l" and th == "l" else None
|
|
232
|
+
ym = (p1[1] + q1[1]) / 2
|
|
233
|
+
mid = [(xo, p1[1]), (xo, q1[1])] if xo is not None else [(p1[0], ym), (q1[0], ym)]
|
|
234
|
+
else:
|
|
235
|
+
yo = max(p1[1], q1[1]) if sh == "b" and th == "b" else min(p1[1], q1[1]) if sh == "t" and th == "t" else None
|
|
236
|
+
xm = (p1[0] + q1[0]) / 2
|
|
237
|
+
mid = [(p1[0], yo), (q1[0], yo)] if yo is not None else [(xm, p1[1]), (xm, q1[1])]
|
|
238
|
+
return [p, p1, *mid, q1, q]
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _seg_hits_box(p: tuple, q: tuple, box: tuple, inset: float = 2.0) -> bool:
|
|
242
|
+
"""Liang-Barsky: does the segment pass through the interior of the box (shrunk by `inset`)?"""
|
|
243
|
+
x1, y1, x2, y2 = box[0] + inset, box[1] + inset, box[2] - inset, box[3] - inset
|
|
244
|
+
if x1 >= x2 or y1 >= y2:
|
|
245
|
+
return False
|
|
246
|
+
dx, dy = q[0] - p[0], q[1] - p[1]
|
|
247
|
+
t0, t1 = 0.0, 1.0
|
|
248
|
+
for pk, qk in ((-dx, p[0] - x1), (dx, x2 - p[0]), (-dy, p[1] - y1), (dy, y2 - p[1])):
|
|
249
|
+
if pk == 0:
|
|
250
|
+
if qk <= 0:
|
|
251
|
+
return False
|
|
252
|
+
else:
|
|
253
|
+
t = qk / pk
|
|
254
|
+
if pk < 0:
|
|
255
|
+
if t > t1:
|
|
256
|
+
return False
|
|
257
|
+
t0 = max(t0, t)
|
|
258
|
+
else:
|
|
259
|
+
if t < t0:
|
|
260
|
+
return False
|
|
261
|
+
t1 = min(t1, t)
|
|
262
|
+
return t0 < t1
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _segs_cross(a: tuple, b: tuple, c: tuple, d: tuple) -> bool:
|
|
266
|
+
def orient(p, q, r):
|
|
267
|
+
v = (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0])
|
|
268
|
+
return 0 if abs(v) < 1e-9 else (1 if v > 0 else -1)
|
|
269
|
+
o1, o2, o3, o4 = orient(a, b, c), orient(a, b, d), orient(c, d, a), orient(c, d, b)
|
|
270
|
+
return o1 * o2 < 0 and o3 * o4 < 0
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _polylines(canvas: dict) -> list[tuple[dict, list]]:
|
|
274
|
+
by = {n["id"]: n for n in _nodes(canvas)}
|
|
275
|
+
out = []
|
|
276
|
+
for e in _edges(canvas):
|
|
277
|
+
if e.get("source") in by and e.get("target") in by and e["source"] != e["target"]:
|
|
278
|
+
out.append((e, _route(e, _box(by[e["source"]]), _box(by[e["target"]]))))
|
|
279
|
+
return out
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _poly_len(pts: list) -> float:
|
|
283
|
+
return sum(math.dist(pts[i], pts[i + 1]) for i in range(len(pts) - 1))
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# ---------------------------------------------------------------- planners shared by doctor and tidy
|
|
287
|
+
|
|
288
|
+
def _cluster(items: list[tuple[str, float]], neighbors: set) -> list[list[tuple[str, float]]]:
|
|
289
|
+
"""Union-find over near values of neighboring nodes. Returns clusters with at least two members."""
|
|
290
|
+
idx = {nid: i for i, (nid, _) in enumerate(items)}
|
|
291
|
+
root = list(range(len(items)))
|
|
292
|
+
|
|
293
|
+
def find(i):
|
|
294
|
+
while root[i] != i:
|
|
295
|
+
root[i] = root[root[i]]
|
|
296
|
+
i = root[i]
|
|
297
|
+
return i
|
|
298
|
+
for i, (a, va) in enumerate(items):
|
|
299
|
+
for j in range(i + 1, len(items)):
|
|
300
|
+
b, vb = items[j]
|
|
301
|
+
if abs(va - vb) <= NEAR_MAX and (a, b) in neighbors:
|
|
302
|
+
root[find(i)] = find(j)
|
|
303
|
+
groups: dict[int, list] = {}
|
|
304
|
+
for nid, v in items:
|
|
305
|
+
groups.setdefault(find(idx[nid]), []).append((nid, v))
|
|
306
|
+
return [g for g in groups.values() if len(g) > 1]
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
_ALIGN_KINDS = {
|
|
310
|
+
"x": (("left", lambda x, s: x), ("center", lambda x, s: x + s / 2), ("right", lambda x, s: x + s)),
|
|
311
|
+
"y": (("top", lambda y, s: y), ("middle", lambda y, s: y + s / 2), ("bottom", lambda y, s: y + s)),
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _would_collide(pos: dict, size: dict, anc: dict, delta: dict[str, float], ai: int) -> bool:
|
|
316
|
+
"""True when shifting the nodes in `delta` along axis `ai` makes an unrelated pair overlap that did not before."""
|
|
317
|
+
def box(i, d=0.0):
|
|
318
|
+
x, y = pos[i]
|
|
319
|
+
x, y = (x + d, y) if ai == 0 else (x, y + d)
|
|
320
|
+
return (x, y, x + size[i][0], y + size[i][1])
|
|
321
|
+
for m, d in delta.items():
|
|
322
|
+
for o in pos:
|
|
323
|
+
if o in delta or o in anc[m] or m in anc[o]:
|
|
324
|
+
continue
|
|
325
|
+
nx, ny = _inter(box(m, d), box(o))
|
|
326
|
+
if nx > OVERLAP_TOL and ny > OVERLAP_TOL:
|
|
327
|
+
ox, oy = _inter(box(m), box(o))
|
|
328
|
+
if not (ox > OVERLAP_TOL and oy > OVERLAP_TOL):
|
|
329
|
+
return True
|
|
330
|
+
return False
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _align_cost(subset, pos: dict, size: dict, nbrs: dict[str, set]) -> int:
|
|
334
|
+
"""Near-miss edge pairs minus exact ones, over the pairs that involve a node of . Lower is better."""
|
|
335
|
+
seen, cost = set(), 0
|
|
336
|
+
for a in subset:
|
|
337
|
+
for b in nbrs[a]:
|
|
338
|
+
key = (a, b) if str(a) <= str(b) else (b, a)
|
|
339
|
+
if key in seen:
|
|
340
|
+
continue
|
|
341
|
+
seen.add(key)
|
|
342
|
+
for ai in (0, 1):
|
|
343
|
+
for _, fn in _ALIGN_KINDS["xy"[ai]]:
|
|
344
|
+
d = abs(fn(pos[a][ai], size[a][ai]) - fn(pos[b][ai], size[b][ai]))
|
|
345
|
+
if d < NEAR_MIN:
|
|
346
|
+
cost -= 1
|
|
347
|
+
elif d <= NEAR_MAX:
|
|
348
|
+
cost += 1
|
|
349
|
+
return cost
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _plan_align(nodes: list[dict], parent: dict[str, str], grid: Optional[float],
|
|
353
|
+
movable: dict[str, bool]) -> tuple[dict[str, list[float]], list[dict]]:
|
|
354
|
+
"""Near-miss alignment. Returns (new [x, y] for the nodes that move, one record per fixed cluster)."""
|
|
355
|
+
pos = {n["id"]: [_x(n), _y(n)] for n in nodes}
|
|
356
|
+
size = {n["id"]: (_w(n), _h(n)) for n in nodes}
|
|
357
|
+
anc = {n["id"]: _ancestors(n["id"], parent) for n in nodes}
|
|
358
|
+
desc = {n["id"]: _descendants(n["id"], parent) for n in nodes if n.get("type") == "frame"}
|
|
359
|
+
order = {n["id"]: i for i, n in enumerate(sorted(nodes, key=_order_key))}
|
|
360
|
+
boxes0 = {n["id"]: _box(n) for n in nodes}
|
|
361
|
+
neighbors = set()
|
|
362
|
+
ids = [n["id"] for n in nodes]
|
|
363
|
+
for i, a in enumerate(ids):
|
|
364
|
+
for b in ids[i + 1:]:
|
|
365
|
+
if a in anc[b] or b in anc[a] or _gap_dist(boxes0[a], boxes0[b]) > NEIGHBOR_DIST:
|
|
366
|
+
continue
|
|
367
|
+
neighbors.add((a, b))
|
|
368
|
+
nbrs: dict[str, set] = {i: set() for i in ids}
|
|
369
|
+
for a, b in neighbors:
|
|
370
|
+
nbrs[a].add(b)
|
|
371
|
+
nbrs[b].add(a)
|
|
372
|
+
moved: dict[str, set] = {"x": set(), "y": set()}
|
|
373
|
+
# the interior of a row of three or more is placed by the spacing rule along x, and of a column along y
|
|
374
|
+
pinned = _pinned(nodes)
|
|
375
|
+
records: list[dict] = []
|
|
376
|
+
for axis, ai in (("x", 0), ("y", 1)):
|
|
377
|
+
for kind, fn in _ALIGN_KINDS[axis]:
|
|
378
|
+
items = [(i, fn(pos[i][ai], size[i][ai])) for i in ids]
|
|
379
|
+
for cl in _cluster(items, neighbors):
|
|
380
|
+
# a member of a frame in the same cluster moves with that frame, so it cannot serve as a target
|
|
381
|
+
inside = {nid for nid, _ in cl}
|
|
382
|
+
cl = [(nid, v) for nid, v in cl if not (anc[nid] & inside)]
|
|
383
|
+
if len(cl) < 2:
|
|
384
|
+
continue
|
|
385
|
+
vals = [v for _, v in cl]
|
|
386
|
+
if max(vals) - min(vals) > 2 * NEAR_MAX or not any(NEAR_MIN <= abs(a - b) for a in vals for b in vals):
|
|
387
|
+
continue
|
|
388
|
+
fixed = {nid: v for nid, v in cl if not movable.get(nid, False) or nid in moved[axis] or nid in pinned[axis]}
|
|
389
|
+
if len({round(v, 3) for v in fixed.values()}) > 1:
|
|
390
|
+
continue
|
|
391
|
+
if fixed:
|
|
392
|
+
target = next(iter(fixed.values()))
|
|
393
|
+
else:
|
|
394
|
+
best = None
|
|
395
|
+
for t in sorted(set(vals)):
|
|
396
|
+
score = (-sum(1 for v in vals if v == t), sum(abs(v - t) for v in vals), t)
|
|
397
|
+
if best is None or score < best[0]:
|
|
398
|
+
best = (score, t)
|
|
399
|
+
target = best[1]
|
|
400
|
+
target = round(target)
|
|
401
|
+
movers = [(nid, v) for nid, v in cl if nid not in fixed and abs(v - target) >= 0.5]
|
|
402
|
+
if not movers:
|
|
403
|
+
continue
|
|
404
|
+
shifts = {}
|
|
405
|
+
ok = True
|
|
406
|
+
for nid, v in movers:
|
|
407
|
+
new = pos[nid][ai] + (target - v)
|
|
408
|
+
if grid and kind not in ("left", "top") and abs(new / grid - round(new / grid)) > 1e-9:
|
|
409
|
+
ok = False
|
|
410
|
+
break
|
|
411
|
+
shifts[nid] = target - v
|
|
412
|
+
if not ok:
|
|
413
|
+
continue
|
|
414
|
+
delta: dict[str, float] = {}
|
|
415
|
+
for nid, d in shifts.items():
|
|
416
|
+
delta[nid] = d
|
|
417
|
+
for k in desc.get(nid, []):
|
|
418
|
+
if movable.get(k, False):
|
|
419
|
+
delta[k] = d
|
|
420
|
+
if _would_collide(pos, size, anc, delta, ai):
|
|
421
|
+
continue
|
|
422
|
+
before = _align_cost(delta, pos, size, nbrs)
|
|
423
|
+
for nid, d in delta.items():
|
|
424
|
+
pos[nid][ai] += d
|
|
425
|
+
if _align_cost(delta, pos, size, nbrs) >= before: # must leave fewer near-misses than exact matches lost
|
|
426
|
+
for nid, d in delta.items():
|
|
427
|
+
pos[nid][ai] -= d
|
|
428
|
+
continue
|
|
429
|
+
moved[axis].update(delta)
|
|
430
|
+
records.append({"kind": kind, "axis": axis, "nodes": [nid for nid, _ in cl],
|
|
431
|
+
"spread": max(vals) - min(vals), "movers": sorted(shifts)})
|
|
432
|
+
orig = {n["id"]: (_x(n), _y(n)) for n in nodes}
|
|
433
|
+
out = {i: pos[i] for i in ids if abs(pos[i][0] - orig[i][0]) > 1e-9 or abs(pos[i][1] - orig[i][1]) > 1e-9}
|
|
434
|
+
records.sort(key=lambda r: (min(order[i] for i in r["nodes"]), r["kind"]))
|
|
435
|
+
return out, records
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _bands(nodes: list[dict], axis: str) -> list[list[dict]]:
|
|
439
|
+
"""Rows (axis 'x') or columns (axis 'y'): groups of nodes sharing at least half of the smaller extent."""
|
|
440
|
+
lo, hi = (1, 3) if axis == "x" else (0, 2)
|
|
441
|
+
bands: list[list] = []
|
|
442
|
+
for n in sorted(nodes, key=lambda n: ((_box(n)[lo] + _box(n)[hi]) / 2, _order_key(n))):
|
|
443
|
+
b = _box(n)
|
|
444
|
+
for band in bands:
|
|
445
|
+
a = _box(band[0])
|
|
446
|
+
ov = min(a[hi], b[hi]) - max(a[lo], b[lo])
|
|
447
|
+
if ov >= 0.5 * min(a[hi] - a[lo], b[hi] - b[lo]):
|
|
448
|
+
band.append(n)
|
|
449
|
+
break
|
|
450
|
+
else:
|
|
451
|
+
bands.append([n])
|
|
452
|
+
return [bd for bd in bands if len(bd) >= 3]
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _pinned(nodes: list[dict]) -> dict[str, set]:
|
|
456
|
+
"""Ids whose x (interior of a row) or y (interior of a column) is owned by the spacing rule."""
|
|
457
|
+
cand = [n for n in nodes if n.get("type") != "frame"]
|
|
458
|
+
out: dict[str, set] = {"x": set(), "y": set()}
|
|
459
|
+
for axis, ai in (("x", 0), ("y", 1)):
|
|
460
|
+
for band in _bands(cand, axis):
|
|
461
|
+
band = sorted(band, key=lambda n: (_box(n)[ai], str(n["id"])))
|
|
462
|
+
out[axis] |= {n["id"] for n in band[1:-1]}
|
|
463
|
+
return out
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _plan_spacing(nodes: list[dict], parent: dict[str, str], grid: Optional[float], movable: dict[str, bool]) -> tuple[dict[str, list[float]], list[dict]]:
|
|
467
|
+
"""Even gaps along rows and columns of three or more. First and last stay put. Returns (moves, records)."""
|
|
468
|
+
tol = max(4.0, grid or 0.0)
|
|
469
|
+
pos = {n["id"]: [_x(n), _y(n)] for n in nodes}
|
|
470
|
+
sizes = {n["id"]: (_w(n), _h(n)) for n in nodes}
|
|
471
|
+
anc = {n["id"]: _ancestors(n["id"], parent) for n in nodes}
|
|
472
|
+
cand = [n for n in nodes if n.get("type") != "frame"]
|
|
473
|
+
records: list[dict] = []
|
|
474
|
+
for axis, ai in (("x", 0), ("y", 1)):
|
|
475
|
+
lo, hi = (0, 2) if axis == "x" else (1, 3)
|
|
476
|
+
size = (lambda n: _w(n)) if axis == "x" else (lambda n: _h(n))
|
|
477
|
+
# a row (gaps along x) is a band of nodes sharing vertical extent
|
|
478
|
+
for band in _bands(cand, "x" if axis == "x" else "y"):
|
|
479
|
+
row = sorted(band, key=lambda n: (pos[n["id"]][ai], str(n["id"])))
|
|
480
|
+
gaps = [pos[row[i + 1]["id"]][ai] - (pos[row[i]["id"]][ai] + size(row[i])) for i in range(len(row) - 1)]
|
|
481
|
+
if min(gaps) < 0:
|
|
482
|
+
continue
|
|
483
|
+
spread, mean = max(gaps) - min(gaps), sum(gaps) / len(gaps)
|
|
484
|
+
if spread <= tol or spread > max(80.0, 0.75 * mean):
|
|
485
|
+
continue
|
|
486
|
+
interior = row[1:-1]
|
|
487
|
+
if any(not movable.get(n["id"], False) for n in interior):
|
|
488
|
+
continue
|
|
489
|
+
total = pos[row[-1]["id"]][ai] - (pos[row[0]["id"]][ai] + size(row[0]))
|
|
490
|
+
gap = (total - sum(size(n) for n in interior)) / (len(row) - 1)
|
|
491
|
+
cur = pos[row[0]["id"]][ai] + size(row[0]) + gap
|
|
492
|
+
delta = {}
|
|
493
|
+
for n in interior:
|
|
494
|
+
new = round(cur)
|
|
495
|
+
if grid:
|
|
496
|
+
new = round(round(cur / grid) * grid)
|
|
497
|
+
delta[n["id"]] = new - pos[n["id"]][ai]
|
|
498
|
+
cur += size(n) + gap
|
|
499
|
+
if _would_collide(pos, sizes, anc, delta, ai):
|
|
500
|
+
continue
|
|
501
|
+
for nid, d in delta.items():
|
|
502
|
+
pos[nid][ai] += d
|
|
503
|
+
records.append({"axis": axis, "nodes": [n["id"] for n in row], "gaps": [round(g, 1) for g in gaps],
|
|
504
|
+
"target": round(gap, 1)})
|
|
505
|
+
orig = {n["id"]: (_x(n), _y(n)) for n in nodes}
|
|
506
|
+
out = {i: p for i, p in pos.items() if abs(p[0] - orig[i][0]) > 1e-9 or abs(p[1] - orig[i][1]) > 1e-9}
|
|
507
|
+
return out, records
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _outside_frame(nodes: list[dict], parent: dict[str, str]) -> list[tuple[dict, dict]]:
|
|
511
|
+
by = {n["id"]: n for n in nodes}
|
|
512
|
+
out = []
|
|
513
|
+
for n in nodes:
|
|
514
|
+
f = by.get(parent.get(n["id"], ""))
|
|
515
|
+
if f is None:
|
|
516
|
+
continue
|
|
517
|
+
b, fb = _box(n), _box(f)
|
|
518
|
+
if b[0] < fb[0] - 1 or b[1] < fb[1] - 1 or b[2] > fb[2] + 1 or b[3] > fb[3] + 1:
|
|
519
|
+
out.append((n, f))
|
|
520
|
+
return out
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _bad_handles(canvas: dict) -> list[tuple[dict, dict]]:
|
|
524
|
+
"""(edge, {'source': side-or-None, 'target': ...}) for connectors leaving a side that faces away from the other end."""
|
|
525
|
+
by = {n["id"]: n for n in _nodes(canvas)}
|
|
526
|
+
out = []
|
|
527
|
+
for e in _edges(canvas):
|
|
528
|
+
a, b = by.get(e.get("source")), by.get(e.get("target"))
|
|
529
|
+
if a is None or b is None or a is b:
|
|
530
|
+
continue
|
|
531
|
+
ab, bb = _box(a), _box(b)
|
|
532
|
+
bad = {}
|
|
533
|
+
if e.get("source_handle") and _faces_away(ab, e["source_handle"], bb):
|
|
534
|
+
bad["source"] = _facing(ab, bb)
|
|
535
|
+
if e.get("target_handle") and _faces_away(bb, e["target_handle"], ab):
|
|
536
|
+
bad["target"] = _facing(bb, ab)
|
|
537
|
+
if bad:
|
|
538
|
+
out.append((e, bad))
|
|
539
|
+
return out
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _text_of(n: dict) -> str:
|
|
543
|
+
return str(n.get("label") if n.get("label") is not None else (n.get("title") or ""))
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _label_fit(n: dict) -> Optional[tuple[int, int]]:
|
|
547
|
+
"""None when the label fits its box, else a size (w, h) it would need. A character-width estimate."""
|
|
548
|
+
t = n.get("type")
|
|
549
|
+
if t == "placeholder":
|
|
550
|
+
name = str((n.get("placeholder") or {}).get("name") or "")
|
|
551
|
+
need = len(name) * 8.2 + 40
|
|
552
|
+
return (int(math.ceil(need)), int(_h(n))) if name and need > _w(n) else None
|
|
553
|
+
text = _text_of(n)
|
|
554
|
+
if not text:
|
|
555
|
+
return None
|
|
556
|
+
if t not in SKETCH: # a window's title shares its bar with buttons
|
|
557
|
+
need = len(text) * 7.4 + 90
|
|
558
|
+
return (int(math.ceil(need)), int(_h(n))) if need > _w(n) else None
|
|
559
|
+
fs = _num(n.get("font_size"), 14.0) or 14.0
|
|
560
|
+
cw, lh = 0.56 * fs, 1.3 * fs
|
|
561
|
+
f = {"ellipse": 0.7, "diamond": 0.5, "cylinder": 0.9}.get(n.get("shape", ""), 1.0) if t == "shape" else 1.0
|
|
562
|
+
usable_w, usable_h = _w(n) * f - 16, _h(n) * f - 8
|
|
563
|
+
if t == "frame": # one line in the title bar
|
|
564
|
+
need = len(text) * cw + 16
|
|
565
|
+
return (int(math.ceil(need)), int(_h(n))) if need > _w(n) else None
|
|
566
|
+
lines, cur = 1, 0.0
|
|
567
|
+
longest = 0.0
|
|
568
|
+
for para in text.split("\n"):
|
|
569
|
+
cur = 0.0
|
|
570
|
+
for word in para.split(" "):
|
|
571
|
+
ww = len(word) * cw
|
|
572
|
+
longest = max(longest, ww)
|
|
573
|
+
if cur and cur + cw + ww > usable_w:
|
|
574
|
+
lines += 1
|
|
575
|
+
cur = ww
|
|
576
|
+
else:
|
|
577
|
+
cur += (cw if cur else 0) + ww
|
|
578
|
+
lines += 1
|
|
579
|
+
lines -= 1
|
|
580
|
+
need_h = lines * lh
|
|
581
|
+
if longest > usable_w or need_h > usable_h:
|
|
582
|
+
return (int(math.ceil(max(longest + 16, _w(n) * f) / f)), int(math.ceil((need_h + 8) / f)))
|
|
583
|
+
return None
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def fit_size(n: dict, max_w: float = MAX_FIT_W) -> tuple[int, int]:
|
|
587
|
+
"""The node's size grown just enough for its label to fit by the doctor's estimate; never smaller than it is now.
|
|
588
|
+
|
|
589
|
+
Shape, text and sticky labels widen to their one-line width up to `max_w`, then wrap and grow taller. A longer
|
|
590
|
+
single word still widens the node, since it cannot wrap. Frame titles, window titles and placeholder names are one
|
|
591
|
+
line, so they widen as far as they need."""
|
|
592
|
+
w, h = _w(n), _h(n)
|
|
593
|
+
probe = copy.deepcopy(n)
|
|
594
|
+
t = n.get("type")
|
|
595
|
+
if t in ("shape", "text", "sticky"):
|
|
596
|
+
text = _text_of(n)
|
|
597
|
+
if text:
|
|
598
|
+
fs = _num(n.get("font_size"), 14.0) or 14.0
|
|
599
|
+
f = {"ellipse": 0.7, "diamond": 0.5, "cylinder": 0.9}.get(n.get("shape", ""), 1.0) if t == "shape" else 1.0
|
|
600
|
+
one_line = (max(len(p) for p in text.split("\n")) * 0.56 * fs + 16) / f
|
|
601
|
+
w = max(w, min(one_line, max_w))
|
|
602
|
+
for _ in range(4):
|
|
603
|
+
_put(probe, w=w, h=h)
|
|
604
|
+
need = _label_fit(probe)
|
|
605
|
+
if need is None:
|
|
606
|
+
break
|
|
607
|
+
w, h = max(w, need[0]), max(h, need[1])
|
|
608
|
+
return (int(math.ceil(w)), int(math.ceil(h)))
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
# ---------------------------------------------------------------- connector labels
|
|
612
|
+
#
|
|
613
|
+
# Placement rule (the web canvas follows the same one, in web/src/sketch/labels.ts): a connector's label is centered
|
|
614
|
+
# at the midpoint of the longest segment of its route that no other connector runs along, moved along that segment by
|
|
615
|
+
# the edge's `label_offset` px (0 when absent; toward the end of the segment when positive). When every segment is
|
|
616
|
+
# shared the longest one is used. `tidy` sets `label_offset` to nudge a label off whatever it would collide with.
|
|
617
|
+
|
|
618
|
+
def _label_size(text: str) -> tuple[float, float]:
|
|
619
|
+
lines = text.split("\n")
|
|
620
|
+
return (max(len(l) for l in lines) * LABEL_CHAR_W + LABEL_PAD[0], len(lines) * LABEL_LINE_H + LABEL_PAD[1])
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _segments(pts: list) -> list[tuple]:
|
|
624
|
+
"""The straight runs of a route: repeated points dropped, and consecutive segments that continue the same line merged."""
|
|
625
|
+
segs: list[tuple] = []
|
|
626
|
+
for p, q in zip(pts, pts[1:]):
|
|
627
|
+
if math.dist(p, q) <= 1e-6:
|
|
628
|
+
continue
|
|
629
|
+
if segs:
|
|
630
|
+
a, b = segs[-1]
|
|
631
|
+
cross = (b[0] - a[0]) * (q[1] - b[1]) - (b[1] - a[1]) * (q[0] - b[0])
|
|
632
|
+
dot = (b[0] - a[0]) * (q[0] - b[0]) + (b[1] - a[1]) * (q[1] - b[1])
|
|
633
|
+
if abs(cross) < 1e-6 * max(1.0, math.dist(a, b) * math.dist(b, q)) and dot > 0:
|
|
634
|
+
segs[-1] = (a, q)
|
|
635
|
+
continue
|
|
636
|
+
segs.append((p, q))
|
|
637
|
+
return segs
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _runs_along(p: tuple, q: tuple, r: tuple, s: tuple) -> bool:
|
|
641
|
+
"""Do segments pq and rs lie on one line and overlap by more than SHARE_TOL?"""
|
|
642
|
+
d = (q[0] - p[0], q[1] - p[1])
|
|
643
|
+
ln = math.hypot(*d)
|
|
644
|
+
if ln < 1e-9:
|
|
645
|
+
return False
|
|
646
|
+
u = (d[0] / ln, d[1] / ln)
|
|
647
|
+
if any(abs((pt[0] - p[0]) * u[1] - (pt[1] - p[1]) * u[0]) > SHARE_TOL for pt in (r, s)):
|
|
648
|
+
return False
|
|
649
|
+
a, b = (r[0] - p[0]) * u[0] + (r[1] - p[1]) * u[1], (s[0] - p[0]) * u[0] + (s[1] - p[1]) * u[1]
|
|
650
|
+
return min(ln, max(a, b)) - max(0.0, min(a, b)) > SHARE_TOL
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def _label_plan(canvas: dict) -> dict[str, dict]:
|
|
654
|
+
"""For each labeled connector with both ends on the canvas: its anchor segment and label size."""
|
|
655
|
+
polys = _polylines(canvas)
|
|
656
|
+
out: dict[str, dict] = {}
|
|
657
|
+
for e, pts in polys:
|
|
658
|
+
text = e.get("label")
|
|
659
|
+
if not isinstance(text, str) or not text:
|
|
660
|
+
continue
|
|
661
|
+
segs = _segments(pts) or [(pts[0], (pts[0][0] + 1.0, pts[0][1]))]
|
|
662
|
+
others = [sg for e2, pts2 in polys if e2 is not e for sg in _segments(pts2)]
|
|
663
|
+
free = [sg for sg in segs if not any(_runs_along(sg[0], sg[1], o[0], o[1]) for o in others)]
|
|
664
|
+
a, b = max(free or segs, key=lambda sg: math.dist(sg[0], sg[1])) # max keeps the first of equals
|
|
665
|
+
ln = math.dist(a, b)
|
|
666
|
+
out[e["id"]] = {"edge": e, "mid": ((a[0] + b[0]) / 2, (a[1] + b[1]) / 2), "len": ln,
|
|
667
|
+
"dir": ((b[0] - a[0]) / ln, (b[1] - a[1]) / ln) if ln > 1e-9 else (1.0, 0.0),
|
|
668
|
+
"size": _label_size(text)}
|
|
669
|
+
return out
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def _label_box_at(info: dict, offset: float) -> tuple[float, float, float, float]:
|
|
673
|
+
half = info["len"] / 2
|
|
674
|
+
o = max(-half, min(half, offset))
|
|
675
|
+
cx, cy = info["mid"][0] + info["dir"][0] * o, info["mid"][1] + info["dir"][1] * o
|
|
676
|
+
w, h = info["size"]
|
|
677
|
+
return (cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2)
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def edge_label_boxes(canvas: dict) -> dict[str, tuple[float, float, float, float]]:
|
|
681
|
+
"""The estimated box of every connector label, by connector id, where both renderers draw it."""
|
|
682
|
+
return {eid: _label_box_at(i, _num(i["edge"].get("label_offset"))) for eid, i in _label_plan(canvas).items()}
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def _collides(a: tuple, b: tuple) -> bool:
|
|
686
|
+
ox, oy = _inter(a, b)
|
|
687
|
+
return ox > 1.0 and oy > 1.0
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _label_collisions(canvas: dict) -> list[tuple[str, str, str]]:
|
|
691
|
+
"""(connector id, 'label' or 'node', other id) for each connector label that overlaps another label or a node."""
|
|
692
|
+
boxes = edge_label_boxes(canvas)
|
|
693
|
+
solid = [n for n in _nodes(canvas) if n.get("type") != "frame"]
|
|
694
|
+
out = []
|
|
695
|
+
ids = sorted(boxes, key=str)
|
|
696
|
+
for i, a in enumerate(ids):
|
|
697
|
+
for b in ids[i + 1:]:
|
|
698
|
+
if _collides(boxes[a], boxes[b]):
|
|
699
|
+
out.append((a, "label", b))
|
|
700
|
+
for n in solid:
|
|
701
|
+
if _collides(boxes[a], _box(n)):
|
|
702
|
+
out.append((a, "node", n["id"]))
|
|
703
|
+
return out
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
# ---------------------------------------------------------------- doctor
|
|
707
|
+
|
|
708
|
+
def _issue(kind: str, key: str, nodes: list[str], message: str, fix: Optional[dict] = None, **extra) -> dict:
|
|
709
|
+
out = {"id": f"{kind}:{key}", "kind": kind, "severity": SEVERITY[kind], "nodes": nodes, "message": message, "fix": fix}
|
|
710
|
+
out.update(extra)
|
|
711
|
+
return out
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _tidy_fix(rules: list[str], ids: list[str]) -> dict:
|
|
715
|
+
return {"tool": "tidy", "rules": rules, "ids": ids}
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def _label(n: dict) -> str:
|
|
719
|
+
t = _text_of(n)
|
|
720
|
+
return f'{n["id"]} "{t[:24]}"' if t else str(n["id"])
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def doctor(canvas: dict, grid: Optional[float] = None) -> dict:
|
|
724
|
+
"""Score a layout from 0 to 100 and list what is wrong with it, each issue with a fix when one exists."""
|
|
725
|
+
nodes, g = _nodes(canvas), _grid_of(canvas, grid)
|
|
726
|
+
by = {n["id"]: n for n in nodes}
|
|
727
|
+
parent = _parents(nodes)
|
|
728
|
+
issues: list[dict] = []
|
|
729
|
+
locked = {n["id"]: n.get("locked") is True for n in nodes}
|
|
730
|
+
|
|
731
|
+
pairs = _overlap_pairs(nodes, parent)
|
|
732
|
+
for a, b, ox, oy in pairs:
|
|
733
|
+
issues.append(_issue("overlap", f'{a["id"]}|{b["id"]}', [a["id"], b["id"]],
|
|
734
|
+
f'{_label(a)} and {_label(b)} overlap by {ox:.0f} x {oy:.0f} px',
|
|
735
|
+
_tidy_fix(["overlaps"], [a["id"], b["id"]]) if not (locked[a["id"]] and locked[b["id"]]) else None))
|
|
736
|
+
|
|
737
|
+
movable = {n["id"]: not locked[n["id"]] for n in nodes}
|
|
738
|
+
_, align_records = _plan_align(nodes, parent, g, movable)
|
|
739
|
+
for r in align_records:
|
|
740
|
+
issues.append(_issue("near_alignment", f'{r["kind"]}:{"|".join(r["nodes"])}', r["nodes"],
|
|
741
|
+
f'{r["kind"]} of {", ".join(r["nodes"])} differ by up to {r["spread"]:.0f} px',
|
|
742
|
+
_tidy_fix(["align"], r["nodes"])))
|
|
743
|
+
|
|
744
|
+
_, space_records = _plan_spacing(nodes, parent, g, movable)
|
|
745
|
+
for r in space_records:
|
|
746
|
+
axis = "row" if r["axis"] == "x" else "column"
|
|
747
|
+
issues.append(_issue("uneven_spacing", f'{axis}:{"|".join(r["nodes"])}', r["nodes"],
|
|
748
|
+
f'gaps in the {axis} {", ".join(r["nodes"])} are {r["gaps"]}; even would be {r["target"]}',
|
|
749
|
+
_tidy_fix(["spacing"], r["nodes"])))
|
|
750
|
+
|
|
751
|
+
polys = _polylines(canvas)
|
|
752
|
+
for e, pts in polys:
|
|
753
|
+
for n in nodes:
|
|
754
|
+
if n["id"] in (e["source"], e["target"]) or n.get("type") == "frame":
|
|
755
|
+
continue
|
|
756
|
+
if any(_seg_hits_box(pts[i], pts[i + 1], _box(n)) for i in range(len(pts) - 1)):
|
|
757
|
+
issues.append(_issue("edge_through_node", f'{e["id"]}|{n["id"]}', [e["source"], e["target"], n["id"]],
|
|
758
|
+
f'connector {e["id"]} ({e["source"]} to {e["target"]}) passes through {_label(n)}',
|
|
759
|
+
None, edges=[e["id"]]))
|
|
760
|
+
crossings = 0
|
|
761
|
+
crossing_nodes: list[str] = []
|
|
762
|
+
for i in range(len(polys)):
|
|
763
|
+
for j in range(i + 1, len(polys)):
|
|
764
|
+
e1, p1 = polys[i]
|
|
765
|
+
e2, p2 = polys[j]
|
|
766
|
+
if {e1["source"], e1["target"]} & {e2["source"], e2["target"]}:
|
|
767
|
+
continue
|
|
768
|
+
hit = sum(1 for a in range(len(p1) - 1) for b in range(len(p2) - 1)
|
|
769
|
+
if _segs_cross(p1[a], p1[a + 1], p2[b], p2[b + 1]))
|
|
770
|
+
if hit:
|
|
771
|
+
crossings += hit
|
|
772
|
+
crossing_nodes += [e1["source"], e1["target"], e2["source"], e2["target"]]
|
|
773
|
+
if crossings:
|
|
774
|
+
ids = sorted(set(crossing_nodes), key=str)
|
|
775
|
+
issues.append(_issue("edge_crossings", "all", ids,
|
|
776
|
+
f"{crossings} connector crossing{'s' if crossings != 1 else ''}; "
|
|
777
|
+
"arrange the connected nodes as a layered flow to reduce them", None, count=crossings))
|
|
778
|
+
for e, bad in _bad_handles(canvas):
|
|
779
|
+
side = ", ".join(f'{k} {e[k + "_handle"]}' for k in bad)
|
|
780
|
+
issues.append(_issue("bad_handle_side", e["id"], [e["source"], e["target"]],
|
|
781
|
+
f'connector {e["id"]} uses a side facing away from the other end ({side})',
|
|
782
|
+
_tidy_fix(["handles"], [e["source"], e["target"]]), edges=[e["id"]]))
|
|
783
|
+
for n, f in _outside_frame(nodes, parent):
|
|
784
|
+
issues.append(_issue("outside_frame", n["id"], [n["id"], f["id"]],
|
|
785
|
+
f'{_label(n)} mostly sits in frame {_label(f)} but sticks out of it',
|
|
786
|
+
None if locked[f["id"]] else _tidy_fix(["frames"], [f["id"]])))
|
|
787
|
+
for n in nodes:
|
|
788
|
+
need = _label_fit(n)
|
|
789
|
+
if need:
|
|
790
|
+
issues.append(_issue("label_overflow", n["id"], [n["id"]],
|
|
791
|
+
f'the label of {_label(n)} probably does not fit; it needs about {need[0]} x {need[1]} px; '
|
|
792
|
+
'tidy with fit_labels grows the node to fit',
|
|
793
|
+
None if locked[n["id"]] else _tidy_fix(["fit_labels"], [n["id"]]),
|
|
794
|
+
needs={"w": need[0], "h": need[1]}))
|
|
795
|
+
edge_by = {e["id"]: e for e in _edges(canvas)}
|
|
796
|
+
for eid, kind, other in _label_collisions(canvas):
|
|
797
|
+
e = edge_by[eid]
|
|
798
|
+
ends = [e["source"], e["target"]]
|
|
799
|
+
if kind == "label":
|
|
800
|
+
o = edge_by[other]
|
|
801
|
+
ids = sorted(set(ends + [o["source"], o["target"]]), key=str)
|
|
802
|
+
issues.append(_issue("label_collision", f"{eid}|{other}", ids,
|
|
803
|
+
f'the labels of connectors {eid} and {other} overlap',
|
|
804
|
+
_tidy_fix(["place_labels"], ids), edges=[eid, other]))
|
|
805
|
+
else:
|
|
806
|
+
issues.append(_issue("label_collision", f"{eid}|{other}", ends + ([other] if other not in ends else []),
|
|
807
|
+
f'the label of connector {eid} ("{str(e["label"])[:24]}") overlaps {_label(by[other])}',
|
|
808
|
+
_tidy_fix(["place_labels"], ends), edges=[eid]))
|
|
809
|
+
far = _far_away(nodes)
|
|
810
|
+
for n, d in far:
|
|
811
|
+
issues.append(_issue("far_away", n["id"], [n["id"]], f'{_label(n)} is {d:.0f} px from the nearest node', None))
|
|
812
|
+
if g:
|
|
813
|
+
off = [n["id"] for n in nodes if abs(_x(n) / g - round(_x(n) / g)) * g > 0.01 or abs(_y(n) / g - round(_y(n) / g)) * g > 0.01]
|
|
814
|
+
if off:
|
|
815
|
+
issues.append(_issue("off_grid", "all", off, f"{len(off)} node(s) are off the {g:g} px grid",
|
|
816
|
+
_tidy_fix(["grid"], off)))
|
|
817
|
+
|
|
818
|
+
metrics = _metrics(nodes, pairs, polys, crossings, align_records, parent)
|
|
819
|
+
penalty = 0.0
|
|
820
|
+
counts: dict[str, int] = {}
|
|
821
|
+
for i in issues:
|
|
822
|
+
counts[i["kind"]] = counts.get(i["kind"], 0) + 1
|
|
823
|
+
for kind, c in counts.items():
|
|
824
|
+
each, cap = PENALTY[kind]
|
|
825
|
+
penalty += min(cap, each * (crossings if kind == "edge_crossings" else c))
|
|
826
|
+
issues.sort(key=lambda i: ({"error": 0, "warning": 1, "info": 2}[i["severity"]], i["kind"], i["id"]))
|
|
827
|
+
return {"score": max(0, min(100, int(round(100 - penalty)))), "metrics": metrics, "issues": issues}
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def _far_away(nodes: list[dict]) -> list[tuple[dict, float]]:
|
|
831
|
+
flat = [n for n in nodes if n.get("type") != "frame"]
|
|
832
|
+
if len(flat) < 4:
|
|
833
|
+
return []
|
|
834
|
+
dist = {}
|
|
835
|
+
for n in flat:
|
|
836
|
+
dist[n["id"]] = min(_gap_dist(_box(n), _box(m)) for m in flat if m is not n)
|
|
837
|
+
ds = sorted(dist.values())
|
|
838
|
+
median = ds[len(ds) // 2]
|
|
839
|
+
limit = max(400.0, 4 * median)
|
|
840
|
+
return [(n, dist[n["id"]]) for n in flat if dist[n["id"]] > limit]
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def _metrics(nodes, pairs, polys, crossings, align_records, parent) -> dict:
|
|
844
|
+
lens = [_poly_len(p) for _, p in polys]
|
|
845
|
+
mean = sum(lens) / len(lens) if lens else 0.0
|
|
846
|
+
var = sum((v - mean) ** 2 for v in lens) / len(lens) if lens else 0.0
|
|
847
|
+
# alignment: of the neighboring edge pairs that are aligned or nearly so, the share that are exact
|
|
848
|
+
flat = [n for n in nodes]
|
|
849
|
+
exact = near = 0
|
|
850
|
+
for axis in "xy":
|
|
851
|
+
for _, fn in _ALIGN_KINDS[axis]:
|
|
852
|
+
vals = [(n["id"], fn(_x(n) if axis == "x" else _y(n), _w(n) if axis == "x" else _h(n)), _box(n)) for n in flat]
|
|
853
|
+
for i in range(len(vals)):
|
|
854
|
+
for j in range(i + 1, len(vals)):
|
|
855
|
+
if _gap_dist(vals[i][2], vals[j][2]) > NEIGHBOR_DIST:
|
|
856
|
+
continue
|
|
857
|
+
d = abs(vals[i][1] - vals[j][1])
|
|
858
|
+
if d < NEAR_MIN:
|
|
859
|
+
exact += 1
|
|
860
|
+
elif d <= NEAR_MAX:
|
|
861
|
+
near += 1
|
|
862
|
+
solid = [n for n in nodes if n.get("type") != "frame"]
|
|
863
|
+
if nodes:
|
|
864
|
+
bx = [min(_box(n)[0] for n in nodes), min(_box(n)[1] for n in nodes),
|
|
865
|
+
max(_box(n)[2] for n in nodes), max(_box(n)[3] for n in nodes)]
|
|
866
|
+
bbox = max((bx[2] - bx[0]) * (bx[3] - bx[1]), 1.0)
|
|
867
|
+
used = sum(_area(n) for n in solid) - sum(ox * oy for a, b, ox, oy in pairs if a in solid and b in solid)
|
|
868
|
+
ws = max(0.0, min(1.0, 1 - used / bbox))
|
|
869
|
+
else:
|
|
870
|
+
ws = 0.0
|
|
871
|
+
return {
|
|
872
|
+
"node_count": len(nodes), "edge_count": len(polys),
|
|
873
|
+
"overlap_area": round(sum(ox * oy for _, _, ox, oy in pairs), 1),
|
|
874
|
+
"edge_crossings": crossings,
|
|
875
|
+
"edge_length_mean": round(mean, 1), "edge_length_variance": round(var, 1),
|
|
876
|
+
"alignment_score": round(exact / (exact + near), 3) if exact + near else 1.0,
|
|
877
|
+
"whitespace_ratio": round(ws, 3),
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
# ---------------------------------------------------------------- tidy
|
|
882
|
+
|
|
883
|
+
class _Ctx:
|
|
884
|
+
"""A working copy of a canvas plus what the rules need: who may move, frame membership, the grid."""
|
|
885
|
+
|
|
886
|
+
def __init__(self, canvas: dict, ids: Optional[Iterable[str]], grid: Optional[float]):
|
|
887
|
+
self.canvas = copy.deepcopy(canvas)
|
|
888
|
+
self.nodes = _nodes(self.canvas)
|
|
889
|
+
self.by = {n["id"]: n for n in self.nodes}
|
|
890
|
+
self.grid = _grid_of(canvas, grid)
|
|
891
|
+
if ids is None:
|
|
892
|
+
self.allowed: Optional[set] = None
|
|
893
|
+
else:
|
|
894
|
+
self.allowed = set(ids)
|
|
895
|
+
unknown = sorted(self.allowed - set(self.by), key=str)
|
|
896
|
+
if unknown:
|
|
897
|
+
raise LayoutError(f"unknown ids: {', '.join(map(str, unknown))}")
|
|
898
|
+
self.parent: dict[str, str] = {}
|
|
899
|
+
self.refresh()
|
|
900
|
+
|
|
901
|
+
def refresh(self) -> None:
|
|
902
|
+
self.parent = _parents(self.nodes)
|
|
903
|
+
|
|
904
|
+
def descendants(self, nid: str) -> list[str]:
|
|
905
|
+
return _descendants(nid, self.parent)
|
|
906
|
+
|
|
907
|
+
def movable(self, nid: str) -> bool:
|
|
908
|
+
n = self.by[nid]
|
|
909
|
+
if n.get("locked") is True or (self.allowed is not None and nid not in self.allowed):
|
|
910
|
+
return False
|
|
911
|
+
return all(self.by[k].get("locked") is not True for k in self.descendants(nid))
|
|
912
|
+
|
|
913
|
+
def movable_map(self) -> dict[str, bool]:
|
|
914
|
+
return {nid: self.movable(nid) for nid in self.by}
|
|
915
|
+
|
|
916
|
+
def move(self, nid: str, dx: float, dy: float) -> None:
|
|
917
|
+
"""Moves a node, and with a frame its unlocked members. Whole pixels."""
|
|
918
|
+
for k in [nid] + self.descendants(nid):
|
|
919
|
+
n = self.by[k]
|
|
920
|
+
if k != nid and n.get("locked") is True:
|
|
921
|
+
continue
|
|
922
|
+
_put(n, x=_x(n) + dx, y=_y(n) + dy)
|
|
923
|
+
|
|
924
|
+
def set_pos(self, nid: str, x: float, y: float) -> None:
|
|
925
|
+
self.move(nid, x - _x(self.by[nid]), y - _y(self.by[nid]))
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
def _snap_dir(v: float, g: Optional[float], up: bool) -> float:
|
|
929
|
+
if not g:
|
|
930
|
+
return v
|
|
931
|
+
return (math.ceil(v / g - 1e-9) if up else math.floor(v / g + 1e-9)) * g
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
def _rule_frames(ctx: _Ctx) -> bool:
|
|
935
|
+
changed = False
|
|
936
|
+
ctx.refresh()
|
|
937
|
+
for n, f in _outside_frame(ctx.nodes, ctx.parent):
|
|
938
|
+
if f.get("locked") is True or (ctx.allowed is not None and f["id"] not in ctx.allowed):
|
|
939
|
+
continue
|
|
940
|
+
b, fb = _box(n), _box(f)
|
|
941
|
+
x1 = min(fb[0], b[0] - FRAME_PAD) if b[0] < fb[0] - 1 else fb[0]
|
|
942
|
+
y1 = min(fb[1], b[1] - FRAME_PAD) if b[1] < fb[1] - 1 else fb[1]
|
|
943
|
+
x2 = max(fb[2], b[2] + FRAME_PAD) if b[2] > fb[2] + 1 else fb[2]
|
|
944
|
+
y2 = max(fb[3], b[3] + FRAME_PAD) if b[3] > fb[3] + 1 else fb[3]
|
|
945
|
+
if ctx.grid:
|
|
946
|
+
x1, y1 = _snap_dir(x1, ctx.grid, False), _snap_dir(y1, ctx.grid, False)
|
|
947
|
+
x2, y2 = _snap_dir(x2, ctx.grid, True), _snap_dir(y2, ctx.grid, True)
|
|
948
|
+
if (x1, y1, x2, y2) != fb:
|
|
949
|
+
_put(f, x=x1, y=y1, w=x2 - x1, h=y2 - y1)
|
|
950
|
+
changed = True
|
|
951
|
+
return changed
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
def _pair_clearance(ctx: _Ctx, a: str, b: str, ai: int) -> float:
|
|
955
|
+
"""The gap to leave between two nodes pushed apart along axis `ai`: MIN_GAP, or when a labeled connector joins them
|
|
956
|
+
enough for its label (the estimated size along that axis plus padding, at least LABELED_MIN_GAP)."""
|
|
957
|
+
need = float(MIN_GAP)
|
|
958
|
+
for e in _edges(ctx.canvas):
|
|
959
|
+
text = e.get("label")
|
|
960
|
+
if isinstance(text, str) and text and {e.get("source"), e.get("target")} == {a, b}:
|
|
961
|
+
need = max(need, LABELED_MIN_GAP, _label_size(text)[ai] + LABEL_GAP_PAD)
|
|
962
|
+
return need
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
def _make_room(ctx: _Ctx, boxes: dict[str, tuple], growth: dict[str, tuple[float, float]],
|
|
966
|
+
pairs: Iterable[tuple[int, str, str]] = ()) -> bool:
|
|
967
|
+
"""Inserts space where nodes grew, so what lay beyond keeps its gap.
|
|
968
|
+
|
|
969
|
+
`boxes` are the nodes' boxes before the growth and `growth` is how far each node's right edge (x) and bottom edge
|
|
970
|
+
(y) moved. A node beyond a growing edge, overlapping the grown node's span on the other axis, moves outward by the
|
|
971
|
+
growth, and so, transitively, does whatever lies beyond it; two nodes pushed by different growths move by the
|
|
972
|
+
larger chain, not the sum. `pairs` are (axis, near, far) triples forced the same way whether or not the spans
|
|
973
|
+
overlap. Nodes that may not move stay put, and nothing is pulled back, so order and gaps only widen."""
|
|
974
|
+
ctx.refresh()
|
|
975
|
+
forced = set(pairs)
|
|
976
|
+
shift = {nid: [0.0, 0.0] for nid in boxes}
|
|
977
|
+
for ai in (0, 1):
|
|
978
|
+
oa = 1 - ai
|
|
979
|
+
order = sorted(boxes, key=lambda k: (boxes[k][ai], str(k)))
|
|
980
|
+
for i, k in enumerate(order):
|
|
981
|
+
best = 0.0
|
|
982
|
+
kb = boxes[k]
|
|
983
|
+
for p in order[:i]:
|
|
984
|
+
carry = shift[p][ai] + growth.get(p, (0.0, 0.0))[ai]
|
|
985
|
+
if carry <= best:
|
|
986
|
+
continue
|
|
987
|
+
pb = boxes[p]
|
|
988
|
+
beyond = kb[ai] >= pb[ai + 2] - 1.0
|
|
989
|
+
if (beyond and min(pb[oa + 2], kb[oa + 2]) - max(pb[oa], kb[oa]) > 1.0) or (ai, p, k) in forced:
|
|
990
|
+
best = carry
|
|
991
|
+
shift[k][ai] = best if best > 0 and ctx.movable(k) else 0.0
|
|
992
|
+
depth = {nid: len(_ancestors(nid, ctx.parent)) for nid in boxes}
|
|
993
|
+
applied = {nid: [0.0, 0.0] for nid in boxes}
|
|
994
|
+
changed = False
|
|
995
|
+
for k in sorted(boxes, key=lambda k: (depth[k], str(k))):
|
|
996
|
+
dx, dy = (shift[k][i] - applied[k][i] for i in (0, 1))
|
|
997
|
+
dx, dy = (d if d > 1e-9 else 0.0 for d in (dx, dy))
|
|
998
|
+
if not dx and not dy:
|
|
999
|
+
continue
|
|
1000
|
+
ctx.move(k, dx, dy)
|
|
1001
|
+
for d in ctx.descendants(k):
|
|
1002
|
+
if d in applied and ctx.by[d].get("locked") is not True:
|
|
1003
|
+
applied[d][0] += dx
|
|
1004
|
+
applied[d][1] += dy
|
|
1005
|
+
changed = True
|
|
1006
|
+
return changed
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
def _rule_overlaps(ctx: _Ctx) -> bool:
|
|
1010
|
+
skipped: set = set()
|
|
1011
|
+
start = {n["id"]: (_x(n), _y(n)) for n in ctx.nodes}
|
|
1012
|
+
pushes: dict[str, int] = {}
|
|
1013
|
+
stuck: set = set() # nodes squeezed between nodes that cannot move: put back, and left overlapping
|
|
1014
|
+
for _ in range(400):
|
|
1015
|
+
ctx.refresh()
|
|
1016
|
+
order = {n["id"]: i for i, n in enumerate(sorted(ctx.nodes, key=_order_key))}
|
|
1017
|
+
todo = [p for p in _overlap_pairs(ctx.nodes, ctx.parent)
|
|
1018
|
+
if (p[0]["id"], p[1]["id"]) not in skipped and p[0]["id"] not in stuck and p[1]["id"] not in stuck]
|
|
1019
|
+
if not todo:
|
|
1020
|
+
break
|
|
1021
|
+
a, b, ox, oy = todo[0]
|
|
1022
|
+
sideways = ox <= oy # least penetration decides the axis
|
|
1023
|
+
ai = 0 if sideways else 1
|
|
1024
|
+
ca, cb = (_box(a)[ai] + _box(a)[ai + 2]) / 2, (_box(b)[ai] + _box(b)[ai + 2]) / 2
|
|
1025
|
+
# the node further along the axis is pushed further along it, so pushes cascade one way and cannot ping-pong
|
|
1026
|
+
high, low = (a, b) if (ca, order[a["id"]]) >= (cb, order[b["id"]]) else (b, a)
|
|
1027
|
+
hb, lb = _box(high), _box(low)
|
|
1028
|
+
if ctx.movable(high["id"]):
|
|
1029
|
+
gap = _pair_clearance(ctx, high["id"], low["id"], ai)
|
|
1030
|
+
target = _snap_dir(lb[ai + 2] + gap, ctx.grid, True)
|
|
1031
|
+
mover, delta = high, target - hb[ai]
|
|
1032
|
+
elif ctx.movable(low["id"]):
|
|
1033
|
+
gap = _pair_clearance(ctx, high["id"], low["id"], ai)
|
|
1034
|
+
target = _snap_dir(hb[ai] - gap - (lb[ai + 2] - lb[ai]), ctx.grid, False)
|
|
1035
|
+
mover, delta = low, target - lb[ai]
|
|
1036
|
+
else:
|
|
1037
|
+
skipped.add((a["id"], b["id"]))
|
|
1038
|
+
continue
|
|
1039
|
+
ctx.move(mover["id"], delta if sideways else 0, 0 if sideways else delta)
|
|
1040
|
+
pushes[mover["id"]] = pushes.get(mover["id"], 0) + 1
|
|
1041
|
+
if pushes[mover["id"]] > 8:
|
|
1042
|
+
ctx.set_pos(mover["id"], *start[mover["id"]])
|
|
1043
|
+
stuck.add(mover["id"])
|
|
1044
|
+
return any(abs(_x(n) - start[n["id"]][0]) > 1e-9 or abs(_y(n) - start[n["id"]][1]) > 1e-9 for n in ctx.nodes)
|
|
1045
|
+
|
|
1046
|
+
|
|
1047
|
+
def _apply_moves(ctx: _Ctx, moves: dict[str, list[float]]) -> bool:
|
|
1048
|
+
changed = False
|
|
1049
|
+
for nid, (x, y) in moves.items():
|
|
1050
|
+
n = ctx.by[nid]
|
|
1051
|
+
if abs(_x(n) - x) > 1e-9 or abs(_y(n) - y) > 1e-9:
|
|
1052
|
+
_put(n, x=x, y=y)
|
|
1053
|
+
changed = True
|
|
1054
|
+
return changed
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
def _rule_align(ctx: _Ctx) -> bool:
|
|
1058
|
+
ctx.refresh()
|
|
1059
|
+
moves, _ = _plan_align(ctx.nodes, ctx.parent, ctx.grid, ctx.movable_map())
|
|
1060
|
+
return _apply_moves(ctx, moves)
|
|
1061
|
+
|
|
1062
|
+
|
|
1063
|
+
def _rule_spacing(ctx: _Ctx) -> bool:
|
|
1064
|
+
ctx.refresh()
|
|
1065
|
+
moves, _ = _plan_spacing(ctx.nodes, ctx.parent, ctx.grid, ctx.movable_map())
|
|
1066
|
+
return _apply_moves(ctx, moves)
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
def _rule_grid(ctx: _Ctx) -> bool:
|
|
1070
|
+
if not ctx.grid:
|
|
1071
|
+
return False
|
|
1072
|
+
changed = False
|
|
1073
|
+
ctx.refresh()
|
|
1074
|
+
depth = {n["id"]: len(_ancestors(n["id"], ctx.parent)) for n in ctx.nodes}
|
|
1075
|
+
for n in sorted(ctx.nodes, key=lambda n: (depth[n["id"]], _order_key(n))):
|
|
1076
|
+
if not ctx.movable(n["id"]):
|
|
1077
|
+
continue
|
|
1078
|
+
nx, ny = round(_x(n) / ctx.grid) * ctx.grid, round(_y(n) / ctx.grid) * ctx.grid
|
|
1079
|
+
if abs(nx - _x(n)) > 0.01 or abs(ny - _y(n)) > 0.01:
|
|
1080
|
+
ctx.move(n["id"], nx - _x(n), ny - _y(n))
|
|
1081
|
+
changed = True
|
|
1082
|
+
return changed
|
|
1083
|
+
|
|
1084
|
+
|
|
1085
|
+
def _rule_handles(ctx: _Ctx, edge_ids: Optional[set] = None) -> bool:
|
|
1086
|
+
changed = False
|
|
1087
|
+
for e, bad in _bad_handles(ctx.canvas):
|
|
1088
|
+
if edge_ids is not None and e["id"] not in edge_ids:
|
|
1089
|
+
continue
|
|
1090
|
+
if ctx.allowed is not None and e["source"] not in ctx.allowed and e["target"] not in ctx.allowed:
|
|
1091
|
+
continue
|
|
1092
|
+
for end, side in bad.items():
|
|
1093
|
+
e[end + "_handle"] = side
|
|
1094
|
+
changed = True
|
|
1095
|
+
return changed
|
|
1096
|
+
|
|
1097
|
+
|
|
1098
|
+
def _rule_fit_labels(ctx: _Ctx) -> bool:
|
|
1099
|
+
"""Grows nodes whose label overflows to the size `fit_size` gives, and pushes what lies beyond a growing edge outward
|
|
1100
|
+
by the growth (`_make_room`), so the gaps stay. Never shrinks; locked nodes are left alone."""
|
|
1101
|
+
before = {n["id"]: _box(n) for n in ctx.nodes}
|
|
1102
|
+
growth: dict[str, tuple[float, float]] = {}
|
|
1103
|
+
for n in ctx.nodes:
|
|
1104
|
+
if n.get("locked") is True or (ctx.allowed is not None and n["id"] not in ctx.allowed):
|
|
1105
|
+
continue
|
|
1106
|
+
w, h = fit_size(n)
|
|
1107
|
+
if w > _w(n) + 1e-9 or h > _h(n) + 1e-9:
|
|
1108
|
+
growth[n["id"]] = (max(w, _w(n)) - _w(n), max(h, _h(n)) - _h(n))
|
|
1109
|
+
_put(n, w=max(w, _w(n)), h=max(h, _h(n)))
|
|
1110
|
+
if growth:
|
|
1111
|
+
_make_room(ctx, before, growth)
|
|
1112
|
+
return bool(growth)
|
|
1113
|
+
|
|
1114
|
+
|
|
1115
|
+
def _widen_gap(ctx: _Ctx, info: dict) -> bool:
|
|
1116
|
+
"""Makes room between a connector's end nodes when its label's segment is too short for the label: inserts the
|
|
1117
|
+
missing length after the nearer end along the segment's axis, as `_make_room` does for a grown node."""
|
|
1118
|
+
e = info["edge"]
|
|
1119
|
+
ai = 0 if abs(info["dir"][0]) >= abs(info["dir"][1]) else 1
|
|
1120
|
+
need = max(LABELED_MIN_GAP, info["size"][ai] + LABEL_GAP_PAD) - info["len"]
|
|
1121
|
+
if need <= 1e-9:
|
|
1122
|
+
return False
|
|
1123
|
+
a, b = ctx.by[e["source"]], ctx.by[e["target"]]
|
|
1124
|
+
near, far = sorted((a, b), key=lambda n: (_box(n)[ai] + _box(n)[ai + 2]) / 2)
|
|
1125
|
+
if _box(far)[ai] < _box(near)[ai + 2] - 1.0: # the ends overlap along the axis: no gap to widen
|
|
1126
|
+
return False
|
|
1127
|
+
need = math.ceil(need)
|
|
1128
|
+
boxes = {n["id"]: _box(n) for n in ctx.nodes}
|
|
1129
|
+
return _make_room(ctx, boxes, {near["id"]: (need, 0.0) if ai == 0 else (0.0, need)}, [(ai, near["id"], far["id"])])
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
def _rule_place_labels(ctx: _Ctx) -> bool:
|
|
1133
|
+
"""Sets `label_offset` on connectors whose label collides with another label or a node: the nearest offset along
|
|
1134
|
+
the label's segment that clears everything. A label that collides with nothing keeps its offset. When the segment
|
|
1135
|
+
is too short for the label and no offset clears it, the gap between the connector's end nodes is widened instead."""
|
|
1136
|
+
changed = False
|
|
1137
|
+
widened: set = set()
|
|
1138
|
+
for _ in range(len(_edges(ctx.canvas)) + 1):
|
|
1139
|
+
ch, wide = _place_labels_pass(ctx, widened)
|
|
1140
|
+
changed |= ch
|
|
1141
|
+
if not wide:
|
|
1142
|
+
break
|
|
1143
|
+
return changed
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
def _place_labels_pass(ctx: _Ctx, widened: set) -> tuple[bool, bool]:
|
|
1147
|
+
plan = _label_plan(ctx.canvas)
|
|
1148
|
+
solid = [_box(n) for n in ctx.nodes if n.get("type") != "frame"]
|
|
1149
|
+
offsets = {eid: _num(i["edge"].get("label_offset")) for eid, i in plan.items()}
|
|
1150
|
+
free = {eid for eid, i in plan.items() if ctx.allowed is None or i["edge"]["source"] in ctx.allowed or i["edge"]["target"] in ctx.allowed}
|
|
1151
|
+
settled = [eid for eid in plan if eid not in free] # labels that may not move are obstacles from the start
|
|
1152
|
+
changed = False
|
|
1153
|
+
for eid in sorted(free, key=str):
|
|
1154
|
+
info = plan[eid]
|
|
1155
|
+
e = info["edge"]
|
|
1156
|
+
others = list(settled) # a label is checked against those settled before it, so the first of a pair stays put
|
|
1157
|
+
settled.append(eid)
|
|
1158
|
+
|
|
1159
|
+
def clear(o: float) -> bool:
|
|
1160
|
+
b = _label_box_at(info, o)
|
|
1161
|
+
return not any(_collides(b, s) for s in solid) and not any(
|
|
1162
|
+
_collides(b, _label_box_at(plan[k], offsets[k])) for k in others)
|
|
1163
|
+
if clear(offsets[eid]):
|
|
1164
|
+
continue
|
|
1165
|
+
half = int(info["len"] // 2)
|
|
1166
|
+
cands = sorted({v for k in range(0, half // LABEL_STEP + 1) for v in (k * LABEL_STEP, -k * LABEL_STEP)} | {half, -half},
|
|
1167
|
+
key=lambda v: (abs(v), -v))
|
|
1168
|
+
best = next((c for c in cands if clear(c)), None)
|
|
1169
|
+
if best is None and eid not in widened:
|
|
1170
|
+
widened.add(eid)
|
|
1171
|
+
if _widen_gap(ctx, info): # geometry moved: plan again from the new routes
|
|
1172
|
+
return True, True
|
|
1173
|
+
if best is None or best == offsets[eid]:
|
|
1174
|
+
continue
|
|
1175
|
+
offsets[eid] = best
|
|
1176
|
+
if best:
|
|
1177
|
+
e["label_offset"] = _clean(best)
|
|
1178
|
+
else:
|
|
1179
|
+
e.pop("label_offset", None)
|
|
1180
|
+
changed = True
|
|
1181
|
+
return changed, False
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
_RULE_FNS = {"fit_labels": _rule_fit_labels, "frames": _rule_frames, "overlaps": _rule_overlaps, "align": _rule_align,
|
|
1185
|
+
"spacing": _rule_spacing, "grid": _rule_grid, "handles": _rule_handles, "place_labels": _rule_place_labels}
|
|
1186
|
+
|
|
1187
|
+
|
|
1188
|
+
def _check_rules(rules: Optional[Iterable[str]]) -> tuple[str, ...]:
|
|
1189
|
+
if rules is None:
|
|
1190
|
+
return RULES
|
|
1191
|
+
rules = tuple(rules)
|
|
1192
|
+
bad = [r for r in rules if r not in RULES]
|
|
1193
|
+
if bad:
|
|
1194
|
+
raise LayoutError(f"unknown rules: {', '.join(map(str, bad))}; use {', '.join(RULES)}")
|
|
1195
|
+
return tuple(r for r in RULES if r in rules)
|
|
1196
|
+
|
|
1197
|
+
|
|
1198
|
+
def _diff(before: dict, after: dict) -> list[dict]:
|
|
1199
|
+
"""Node geometry changes and connector handle changes between two canvases."""
|
|
1200
|
+
out = []
|
|
1201
|
+
old = {n["id"]: n for n in _nodes(before)}
|
|
1202
|
+
for n in _nodes(after):
|
|
1203
|
+
o = old.get(n["id"])
|
|
1204
|
+
if o is None:
|
|
1205
|
+
continue
|
|
1206
|
+
a = {"x": _x(o), "y": _y(o), "w": _w(o), "h": _num((o.get("size") or {}).get("h", o.get("h")), _h(o))}
|
|
1207
|
+
b = {"x": _x(n), "y": _y(n), "w": _w(n), "h": _num((n.get("size") or {}).get("h", n.get("h")), _h(n))}
|
|
1208
|
+
if any(abs(a[k] - b[k]) > 1e-9 for k in a):
|
|
1209
|
+
out.append({"kind": "node", "id": n["id"], "from": _round_geo(a), "to": _round_geo(b)})
|
|
1210
|
+
olde = {e["id"]: e for e in _edges(before)}
|
|
1211
|
+
for e in _edges(after):
|
|
1212
|
+
o = olde.get(e["id"])
|
|
1213
|
+
if o is None:
|
|
1214
|
+
continue
|
|
1215
|
+
a = {"source_handle": o.get("source_handle"), "target_handle": o.get("target_handle")}
|
|
1216
|
+
b = {"source_handle": e.get("source_handle"), "target_handle": e.get("target_handle")}
|
|
1217
|
+
if _num(o.get("label_offset")) != _num(e.get("label_offset")):
|
|
1218
|
+
a["label_offset"], b["label_offset"] = _num(o.get("label_offset")), _num(e.get("label_offset"))
|
|
1219
|
+
if a != b:
|
|
1220
|
+
out.append({"kind": "edge", "id": e["id"], "from": a, "to": b})
|
|
1221
|
+
return out
|
|
1222
|
+
|
|
1223
|
+
|
|
1224
|
+
def _round_geo(g: dict) -> dict:
|
|
1225
|
+
return {k: (int(v) if float(v).is_integer() else round(v, 2)) for k, v in g.items()}
|
|
1226
|
+
|
|
1227
|
+
|
|
1228
|
+
def tidy(canvas: dict, ids: Optional[Iterable[str]] = None, rules: Optional[Iterable[str]] = None,
|
|
1229
|
+
grid: Optional[float] = None) -> tuple[dict, list[dict]]:
|
|
1230
|
+
"""Fix a layout with the least movement. Returns the new canvas and the changes (second call changes nothing)."""
|
|
1231
|
+
use = _check_rules(rules)
|
|
1232
|
+
ctx = _Ctx(canvas, ids, grid)
|
|
1233
|
+
for _ in range(8):
|
|
1234
|
+
changed = False
|
|
1235
|
+
for r in use:
|
|
1236
|
+
changed |= _RULE_FNS[r](ctx)
|
|
1237
|
+
if not changed:
|
|
1238
|
+
break
|
|
1239
|
+
return ctx.canvas, _diff(canvas, ctx.canvas)
|
|
1240
|
+
|
|
1241
|
+
|
|
1242
|
+
# ---------------------------------------------------------------- arrange
|
|
1243
|
+
|
|
1244
|
+
def _reading_order(nodes: list[dict]) -> list[dict]:
|
|
1245
|
+
"""Top to bottom in bands, left to right inside a band."""
|
|
1246
|
+
out: list[dict] = []
|
|
1247
|
+
rest = sorted(nodes, key=_order_key)
|
|
1248
|
+
while rest:
|
|
1249
|
+
first = rest[0]
|
|
1250
|
+
fb = _box(first)
|
|
1251
|
+
band = [n for n in rest if _box(n)[1] < fb[3] - 0.5 * (fb[3] - fb[1])]
|
|
1252
|
+
band_ids = {n["id"] for n in band}
|
|
1253
|
+
out += sorted(band, key=lambda n: (_x(n), str(n["id"])))
|
|
1254
|
+
rest = [n for n in rest if n["id"] not in band_ids]
|
|
1255
|
+
return out
|
|
1256
|
+
|
|
1257
|
+
|
|
1258
|
+
def _layout_grid(ns: list[dict], cols: int, gap: float, align: str) -> dict[str, tuple[float, float]]:
|
|
1259
|
+
rows = [ns[i:i + cols] for i in range(0, len(ns), cols)]
|
|
1260
|
+
colw = [max((_w(r[c]) for r in rows if c < len(r)), default=0) for c in range(cols)]
|
|
1261
|
+
out, y = {}, 0.0
|
|
1262
|
+
for r in rows:
|
|
1263
|
+
rh = max(_h(n) for n in r)
|
|
1264
|
+
x = 0.0
|
|
1265
|
+
for c, n in enumerate(r):
|
|
1266
|
+
off = {"start": 0.0, "center": (rh - _h(n)) / 2, "end": rh - _h(n)}[align]
|
|
1267
|
+
out[n["id"]] = (x, y + off)
|
|
1268
|
+
x += colw[c] + gap
|
|
1269
|
+
y += rh + gap
|
|
1270
|
+
return out
|
|
1271
|
+
|
|
1272
|
+
|
|
1273
|
+
def _layout_line(ns: list[dict], axis: str, gap: float, align: str) -> dict[str, tuple[float, float]]:
|
|
1274
|
+
out, cur = {}, 0.0
|
|
1275
|
+
cross = max((_h(n) if axis == "x" else _w(n)) for n in ns)
|
|
1276
|
+
for n in ns:
|
|
1277
|
+
size, other = (_w(n), _h(n)) if axis == "x" else (_h(n), _w(n))
|
|
1278
|
+
off = {"start": 0.0, "center": (cross - other) / 2, "end": cross - other}[align]
|
|
1279
|
+
out[n["id"]] = (cur, off) if axis == "x" else (off, cur)
|
|
1280
|
+
cur += size + gap
|
|
1281
|
+
return out
|
|
1282
|
+
|
|
1283
|
+
|
|
1284
|
+
def _layers(ids: list[str], edges: list[tuple[str, str]], index: dict[str, int]) -> tuple[dict[str, int], set]:
|
|
1285
|
+
"""Longest-path layers after reversing back edges found by a depth-first search. Returns (layer, reversed edges)."""
|
|
1286
|
+
succ: dict[str, list[str]] = {i: [] for i in ids}
|
|
1287
|
+
for a, b in edges:
|
|
1288
|
+
if b not in succ[a]:
|
|
1289
|
+
succ[a].append(b)
|
|
1290
|
+
state: dict[str, int] = {}
|
|
1291
|
+
rev: set = set()
|
|
1292
|
+
|
|
1293
|
+
def dfs(u):
|
|
1294
|
+
state[u] = 1
|
|
1295
|
+
for v in sorted(succ[u], key=lambda k: index[k]):
|
|
1296
|
+
if state.get(v) == 1:
|
|
1297
|
+
rev.add((u, v))
|
|
1298
|
+
elif v not in state:
|
|
1299
|
+
dfs(v)
|
|
1300
|
+
state[u] = 2
|
|
1301
|
+
for u in sorted(ids, key=lambda k: index[k]):
|
|
1302
|
+
if u not in state:
|
|
1303
|
+
dfs(u)
|
|
1304
|
+
dag = [(b, a) if (a, b) in rev else (a, b) for a, b in edges]
|
|
1305
|
+
preds: dict[str, set] = {i: set() for i in ids}
|
|
1306
|
+
for a, b in dag:
|
|
1307
|
+
if a != b:
|
|
1308
|
+
preds[b].add(a)
|
|
1309
|
+
layer: dict[str, int] = {}
|
|
1310
|
+
|
|
1311
|
+
def depth(u):
|
|
1312
|
+
if u not in layer:
|
|
1313
|
+
layer[u] = 0 if not preds[u] else 1 + max(depth(p) for p in preds[u])
|
|
1314
|
+
return layer[u]
|
|
1315
|
+
for u in ids:
|
|
1316
|
+
depth(u)
|
|
1317
|
+
return layer, rev
|
|
1318
|
+
|
|
1319
|
+
|
|
1320
|
+
def _order_layers(layers: list[list[str]], edges: list[tuple[str, str]], rev: set, index: dict[str, int]) -> list[list[str]]:
|
|
1321
|
+
nb: dict[str, set] = {i: set() for lay in layers for i in lay}
|
|
1322
|
+
for a, b in edges:
|
|
1323
|
+
if a != b:
|
|
1324
|
+
nb[a].add(b)
|
|
1325
|
+
nb[b].add(a)
|
|
1326
|
+
layers = [sorted(lay, key=lambda k: index[k]) for lay in layers]
|
|
1327
|
+
layer_of = {i: li for li, lay in enumerate(layers) for i in lay}
|
|
1328
|
+
|
|
1329
|
+
def sweep(rng, forward):
|
|
1330
|
+
for li in rng:
|
|
1331
|
+
lay = layers[li]
|
|
1332
|
+
rank = {i: (k / max(len(layers[layer_of[i]]) - 1, 1)) for lj in layers for k, i in enumerate(lj)}
|
|
1333
|
+
bary = {}
|
|
1334
|
+
for k, i in enumerate(lay):
|
|
1335
|
+
ns = [j for j in nb[i] if (layer_of[j] < li if forward else layer_of[j] > li)]
|
|
1336
|
+
bary[i] = sum(rank[j] for j in ns) / len(ns) if ns else rank[i]
|
|
1337
|
+
layers[li] = sorted(lay, key=lambda i: (bary[i], lay.index(i)))
|
|
1338
|
+
for _ in range(6):
|
|
1339
|
+
sweep(range(1, len(layers)), True)
|
|
1340
|
+
sweep(range(len(layers) - 2, -1, -1), False)
|
|
1341
|
+
return layers
|
|
1342
|
+
|
|
1343
|
+
|
|
1344
|
+
def _layout_layered(ns: list[dict], edges: list[dict], direction: str, gap: float, layer_gap: float):
|
|
1345
|
+
by = {n["id"]: n for n in ns}
|
|
1346
|
+
order = _reading_order(ns)
|
|
1347
|
+
index = {n["id"]: i for i, n in enumerate(order)}
|
|
1348
|
+
pairs, seen = [], set()
|
|
1349
|
+
for e in edges:
|
|
1350
|
+
k = (e["source"], e["target"])
|
|
1351
|
+
if e["source"] in by and e["target"] in by and e["source"] != e["target"] and k not in seen:
|
|
1352
|
+
seen.add(k)
|
|
1353
|
+
pairs.append(k)
|
|
1354
|
+
ids = [n["id"] for n in order]
|
|
1355
|
+
layer, rev = _layers(ids, pairs, index)
|
|
1356
|
+
nlayers = max(layer.values(), default=0) + 1
|
|
1357
|
+
layers = [[i for i in ids if layer[i] == li] for li in range(nlayers)]
|
|
1358
|
+
dag = [(b, a) if (a, b) in rev else (a, b) for a, b in pairs]
|
|
1359
|
+
layers = _order_layers(layers, dag, rev, index)
|
|
1360
|
+
lr = direction == "LR"
|
|
1361
|
+
# `pos` runs inside a layer, `along` runs from layer to layer
|
|
1362
|
+
out: dict[str, tuple[float, float]] = {}
|
|
1363
|
+
along = 0.0
|
|
1364
|
+
totals = [sum((_h(by[i]) if lr else _w(by[i])) for i in lay) + gap * max(len(lay) - 1, 0) for lay in layers]
|
|
1365
|
+
widest = max(totals, default=0.0)
|
|
1366
|
+
for li, lay in enumerate(layers):
|
|
1367
|
+
thick = max(((_w(by[i]) if lr else _h(by[i])) for i in lay), default=0.0)
|
|
1368
|
+
pos = (widest - totals[li]) / 2
|
|
1369
|
+
for i in lay:
|
|
1370
|
+
n = by[i]
|
|
1371
|
+
if lr:
|
|
1372
|
+
out[i] = (along + (thick - _w(n)) / 2, pos)
|
|
1373
|
+
pos += _h(n) + gap
|
|
1374
|
+
else:
|
|
1375
|
+
out[i] = (pos, along + (thick - _h(n)) / 2)
|
|
1376
|
+
pos += _w(n) + gap
|
|
1377
|
+
along += thick + layer_gap
|
|
1378
|
+
return out, rev
|
|
1379
|
+
|
|
1380
|
+
|
|
1381
|
+
def _shift_clear(ctx: _Ctx, movers: dict[str, tuple[float, float]], obstacles: list[dict], gap: float,
|
|
1382
|
+
axes: tuple = ("x-", "x+", "y-", "y+")) -> tuple[float, float]:
|
|
1383
|
+
"""Smallest rigid shift of the proposed boxes that clears every obstacle. (0, 0) when nothing is in the way."""
|
|
1384
|
+
boxes = {i: (p[0], p[1], p[0] + _w(ctx.by[i]), p[1] + _h(ctx.by[i])) for i, p in movers.items()}
|
|
1385
|
+
|
|
1386
|
+
def hits(dx, dy):
|
|
1387
|
+
for i, b in boxes.items():
|
|
1388
|
+
sb = (b[0] + dx, b[1] + dy, b[2] + dx, b[3] + dy)
|
|
1389
|
+
n = ctx.by[i]
|
|
1390
|
+
for o in obstacles:
|
|
1391
|
+
ob = _box(o)
|
|
1392
|
+
ox, oy = _inter(sb, ob)
|
|
1393
|
+
if ox > OVERLAP_TOL and oy > OVERLAP_TOL:
|
|
1394
|
+
if o.get("type") == "frame":
|
|
1395
|
+
a = (sb[2] - sb[0]) * (sb[3] - sb[1])
|
|
1396
|
+
if ox * oy >= 0.5 * a:
|
|
1397
|
+
continue # it lands inside the frame and becomes a member
|
|
1398
|
+
return True
|
|
1399
|
+
return False
|
|
1400
|
+
if not hits(0, 0):
|
|
1401
|
+
return (0.0, 0.0)
|
|
1402
|
+
bx = (min(b[0] for b in boxes.values()), min(b[1] for b in boxes.values()),
|
|
1403
|
+
max(b[2] for b in boxes.values()), max(b[3] for b in boxes.values()))
|
|
1404
|
+
cands = []
|
|
1405
|
+
for o in obstacles:
|
|
1406
|
+
ob = _box(o)
|
|
1407
|
+
if "x+" in axes:
|
|
1408
|
+
cands.append((ob[2] + gap - bx[0], 0.0))
|
|
1409
|
+
if "x-" in axes:
|
|
1410
|
+
cands.append((ob[0] - gap - bx[2], 0.0))
|
|
1411
|
+
if "y+" in axes:
|
|
1412
|
+
cands.append((0.0, ob[3] + gap - bx[1]))
|
|
1413
|
+
if "y-" in axes:
|
|
1414
|
+
cands.append((0.0, ob[1] - gap - bx[3]))
|
|
1415
|
+
cands.sort(key=lambda c: (abs(c[0]) + abs(c[1]), c))
|
|
1416
|
+
for dx, dy in cands:
|
|
1417
|
+
if not hits(dx, dy):
|
|
1418
|
+
return (dx, dy)
|
|
1419
|
+
allb = [_box(o) for o in obstacles]
|
|
1420
|
+
far = max(b[2] for b in allb) + gap - bx[0] if "x+" in axes else (max(b[3] for b in allb) + gap - bx[1])
|
|
1421
|
+
return (far, 0.0) if "x+" in axes else (0.0, far)
|
|
1422
|
+
|
|
1423
|
+
|
|
1424
|
+
def arrange(canvas: dict, ids: Optional[Iterable[str]], mode: str, grid: Optional[float] = None, **opts) -> tuple[dict, list[dict]]:
|
|
1425
|
+
"""Lay nodes out from structure. Modes: grid, row, column, layered (direction LR/TB), place (relation, anchor)."""
|
|
1426
|
+
if mode not in ARRANGE_MODES:
|
|
1427
|
+
raise LayoutError(f"unknown mode {mode!r}; use {', '.join(ARRANGE_MODES)}")
|
|
1428
|
+
ctx = _Ctx(canvas, None, grid)
|
|
1429
|
+
ctx.allowed = None
|
|
1430
|
+
wanted = [n["id"] for n in ctx.nodes if n.get("type") != "frame"] if ids is None else list(ids)
|
|
1431
|
+
unknown = sorted(set(wanted) - set(ctx.by), key=str)
|
|
1432
|
+
if unknown:
|
|
1433
|
+
raise LayoutError(f"unknown ids: {', '.join(map(str, unknown))}")
|
|
1434
|
+
gap = _num(opts.get("gap"), 40.0)
|
|
1435
|
+
if gap < 0:
|
|
1436
|
+
raise LayoutError("gap must be 0 or more")
|
|
1437
|
+
align = opts.get("align") or ("center" if mode in ("place", "layered") else "start")
|
|
1438
|
+
if align not in ("start", "center", "end"):
|
|
1439
|
+
raise LayoutError("align must be start, center or end")
|
|
1440
|
+
# a frame in the set carries its members, so members whose frame is also in the set are not laid out themselves
|
|
1441
|
+
chosen = set(wanted)
|
|
1442
|
+
movers = [ctx.by[i] for i in wanted if ctx.by[i].get("locked") is not True
|
|
1443
|
+
and not (_ancestors(i, ctx.parent) & chosen)]
|
|
1444
|
+
if mode == "place":
|
|
1445
|
+
_arrange_place(ctx, movers, opts, gap, align)
|
|
1446
|
+
elif not movers:
|
|
1447
|
+
return ctx.canvas, []
|
|
1448
|
+
else:
|
|
1449
|
+
tl = (min(_x(n) for n in movers), min(_y(n) for n in movers))
|
|
1450
|
+
rev: set = set()
|
|
1451
|
+
if mode == "grid":
|
|
1452
|
+
cols = int(opts.get("cols") or max(1, math.ceil(math.sqrt(len(movers)))))
|
|
1453
|
+
rel = _layout_grid(_reading_order(movers), max(cols, 1), gap, align)
|
|
1454
|
+
elif mode == "row":
|
|
1455
|
+
rel = _layout_line(sorted(movers, key=lambda n: (_x(n), _order_key(n))), "x", gap, align)
|
|
1456
|
+
elif mode == "column":
|
|
1457
|
+
rel = _layout_line(sorted(movers, key=_order_key), "y", gap, align)
|
|
1458
|
+
else:
|
|
1459
|
+
direction = str(opts.get("direction", "LR")).upper()
|
|
1460
|
+
if direction not in ("LR", "TB"):
|
|
1461
|
+
raise LayoutError("direction must be LR or TB")
|
|
1462
|
+
rel, rev = _layout_layered(movers, _edges(ctx.canvas), direction, gap, _num(opts.get("layer_gap"), 2 * gap))
|
|
1463
|
+
target = {i: (tl[0] + p[0], tl[1] + p[1]) for i, p in rel.items()}
|
|
1464
|
+
if ctx.grid:
|
|
1465
|
+
target = {i: (round(p[0] / ctx.grid) * ctx.grid, round(p[1] / ctx.grid) * ctx.grid) for i, p in target.items()}
|
|
1466
|
+
keep = set(_carried(movers, ctx))
|
|
1467
|
+
holders = {a for i in rel for a in _ancestors(i, ctx.parent)} # frames the subset sits in are not in its way
|
|
1468
|
+
obstacles = [n for n in ctx.nodes if n["id"] not in keep and n["id"] not in holders]
|
|
1469
|
+
dx, dy = _shift_clear(ctx, target, obstacles, gap)
|
|
1470
|
+
if ctx.grid:
|
|
1471
|
+
dx, dy = _snap_dir(dx, ctx.grid, dx > 0), _snap_dir(dy, ctx.grid, dy > 0)
|
|
1472
|
+
for i, p in target.items():
|
|
1473
|
+
ctx.set_pos(i, p[0] + dx, p[1] + dy)
|
|
1474
|
+
if mode == "layered":
|
|
1475
|
+
lr = str(opts.get("direction", "LR")).upper() == "LR"
|
|
1476
|
+
inside = set(rel)
|
|
1477
|
+
for e in _edges(ctx.canvas):
|
|
1478
|
+
if e["source"] in inside and e["target"] in inside and e["source"] != e["target"]:
|
|
1479
|
+
if (e["source"], e["target"]) in rev:
|
|
1480
|
+
a, b = _box(ctx.by[e["source"]]), _box(ctx.by[e["target"]])
|
|
1481
|
+
e["source_handle"], e["target_handle"] = _facing(a, b), _facing(b, a)
|
|
1482
|
+
else:
|
|
1483
|
+
e["source_handle"], e["target_handle"] = ("r", "l") if lr else ("b", "t")
|
|
1484
|
+
touched = {e["id"] for e in _edges(ctx.canvas) if e["source"] in chosen or e["target"] in chosen}
|
|
1485
|
+
_rule_handles(ctx, touched)
|
|
1486
|
+
return ctx.canvas, _diff(canvas, ctx.canvas)
|
|
1487
|
+
|
|
1488
|
+
|
|
1489
|
+
def _carried(movers: list[dict], ctx: _Ctx) -> list[str]:
|
|
1490
|
+
"""The movers and every member that travels with them."""
|
|
1491
|
+
out = []
|
|
1492
|
+
for n in movers:
|
|
1493
|
+
out += [n["id"]] + ctx.descendants(n["id"])
|
|
1494
|
+
return out
|
|
1495
|
+
|
|
1496
|
+
|
|
1497
|
+
def _arrange_place(ctx: _Ctx, movers: list[dict], opts: dict, gap: float, align: str) -> None:
|
|
1498
|
+
rel = opts.get("relation") or opts.get("rel")
|
|
1499
|
+
if rel not in PLACE_RELATIONS:
|
|
1500
|
+
raise LayoutError(f"place needs relation: one of {', '.join(PLACE_RELATIONS)}")
|
|
1501
|
+
anchor = ctx.by.get(opts.get("anchor"))
|
|
1502
|
+
if anchor is None:
|
|
1503
|
+
raise LayoutError("place needs an anchor id that exists")
|
|
1504
|
+
ns = [n for n in movers if n["id"] != anchor["id"]]
|
|
1505
|
+
if not ns:
|
|
1506
|
+
return
|
|
1507
|
+
ab = _box(anchor)
|
|
1508
|
+
if rel == "inside":
|
|
1509
|
+
if anchor.get("type") != "frame":
|
|
1510
|
+
raise LayoutError("inside needs an anchor that is a frame")
|
|
1511
|
+
pad, top = FRAME_PAD, FRAME_PAD + 20
|
|
1512
|
+
inner_w = max(ab[2] - ab[0] - 2 * pad, max(_w(n) for n in ns))
|
|
1513
|
+
cols = 1 # as many columns as fit the frame's width
|
|
1514
|
+
while cols < len(ns):
|
|
1515
|
+
w = sum(sorted((_w(n) for n in ns), reverse=True)[:cols + 1]) + gap * cols
|
|
1516
|
+
if w > inner_w:
|
|
1517
|
+
break
|
|
1518
|
+
cols += 1
|
|
1519
|
+
rel_pos = _layout_grid(_reading_order(ns), cols, gap, "start")
|
|
1520
|
+
existing = [m for m in ctx.nodes if m["id"] != anchor["id"] and m["id"] not in {n["id"] for n in ns}
|
|
1521
|
+
and ctx.parent.get(m["id"]) == anchor["id"]]
|
|
1522
|
+
y0 = max([_box(m)[3] + gap for m in existing], default=ab[1] + top)
|
|
1523
|
+
for n in ns:
|
|
1524
|
+
ctx.set_pos(n["id"], ab[0] + pad + rel_pos[n["id"]][0], y0 + rel_pos[n["id"]][1])
|
|
1525
|
+
# grow the frame to hold them
|
|
1526
|
+
ctx.refresh()
|
|
1527
|
+
fb = list(ab)
|
|
1528
|
+
for n in ns:
|
|
1529
|
+
b = _box(n)
|
|
1530
|
+
fb = [min(fb[0], b[0] - pad), min(fb[1], b[1] - pad), max(fb[2], b[2] + pad), max(fb[3], b[3] + pad)]
|
|
1531
|
+
if ctx.grid:
|
|
1532
|
+
fb = [_snap_dir(fb[0], ctx.grid, False), _snap_dir(fb[1], ctx.grid, False),
|
|
1533
|
+
_snap_dir(fb[2], ctx.grid, True), _snap_dir(fb[3], ctx.grid, True)]
|
|
1534
|
+
if anchor.get("locked") is not True:
|
|
1535
|
+
_put(anchor, x=fb[0], y=fb[1], w=fb[2] - fb[0], h=fb[3] - fb[1])
|
|
1536
|
+
return
|
|
1537
|
+
horizontal = rel in ("right_of", "left_of")
|
|
1538
|
+
stack = _layout_line(_reading_order(ns) if horizontal else sorted(ns, key=lambda n: (_x(n), _order_key(n))),
|
|
1539
|
+
"y" if horizontal else "x", _num(opts.get("spacing"), 24.0), align)
|
|
1540
|
+
sw = max(p[0] + _w(ctx.by[i]) for i, p in stack.items())
|
|
1541
|
+
sh = max(p[1] + _h(ctx.by[i]) for i, p in stack.items())
|
|
1542
|
+
if horizontal:
|
|
1543
|
+
x0 = ab[2] + gap if rel == "right_of" else ab[0] - gap - sw
|
|
1544
|
+
y0 = {"start": ab[1], "center": (ab[1] + ab[3]) / 2 - sh / 2, "end": ab[3] - sh}[align]
|
|
1545
|
+
else:
|
|
1546
|
+
y0 = ab[3] + gap if rel == "below" else ab[1] - gap - sh
|
|
1547
|
+
x0 = {"start": ab[0], "center": (ab[0] + ab[2]) / 2 - sw / 2, "end": ab[2] - sw}[align]
|
|
1548
|
+
target = {i: (x0 + p[0], y0 + p[1]) for i, p in stack.items()}
|
|
1549
|
+
if ctx.grid:
|
|
1550
|
+
target = {i: (round(p[0] / ctx.grid) * ctx.grid, round(p[1] / ctx.grid) * ctx.grid) for i, p in target.items()}
|
|
1551
|
+
keep = set(_carried(ns, ctx)) | {anchor["id"]}
|
|
1552
|
+
holders = {a for i in stack for a in _ancestors(i, ctx.parent)} | _ancestors(anchor["id"], ctx.parent)
|
|
1553
|
+
obstacles = [n for n in ctx.nodes if n["id"] not in keep and n["id"] not in holders]
|
|
1554
|
+
axis = {"right_of": ("x+",), "left_of": ("x-",), "below": ("y+",), "above": ("y-",)}[rel]
|
|
1555
|
+
dx, dy = _shift_clear(ctx, target, obstacles, gap, axis)
|
|
1556
|
+
for i, p in target.items():
|
|
1557
|
+
ctx.set_pos(i, p[0] + dx, p[1] + dy)
|
|
1558
|
+
|
|
1559
|
+
|
|
1560
|
+
# ---------------------------------------------------------------- describe
|
|
1561
|
+
|
|
1562
|
+
def _fmt(n: dict) -> str:
|
|
1563
|
+
t = _text_of(n)
|
|
1564
|
+
label = f' "{t[:28]}"' if t else ""
|
|
1565
|
+
return f'{n["id"]}{label} {_clip(_x(n))},{_clip(_y(n))} {_clip(_w(n))}x{_clip(_h(n))}'
|
|
1566
|
+
|
|
1567
|
+
|
|
1568
|
+
def _clip(v: float) -> str:
|
|
1569
|
+
return str(int(round(v)))
|
|
1570
|
+
|
|
1571
|
+
|
|
1572
|
+
def describe(canvas: dict, max_lines: int = 60, verbose: bool = False, grid: Optional[float] = None) -> str:
|
|
1573
|
+
"""A compact text summary for a language model: frames, a coarse map, connectors and the top issues."""
|
|
1574
|
+
nodes, edges = _nodes(canvas), _edges(canvas)
|
|
1575
|
+
by = {n["id"]: n for n in nodes}
|
|
1576
|
+
parent = _parents(nodes)
|
|
1577
|
+
rep = doctor(canvas, grid)
|
|
1578
|
+
kinds: dict[str, int] = {}
|
|
1579
|
+
for n in nodes:
|
|
1580
|
+
kinds[n.get("type", "?")] = kinds.get(n.get("type", "?"), 0) + 1
|
|
1581
|
+
out: list[str] = []
|
|
1582
|
+
if nodes:
|
|
1583
|
+
x1, y1 = min(_box(n)[0] for n in nodes), min(_box(n)[1] for n in nodes)
|
|
1584
|
+
x2, y2 = max(_box(n)[2] for n in nodes), max(_box(n)[3] for n in nodes)
|
|
1585
|
+
extent = f"; extent x {_clip(x1)}..{_clip(x2)}, y {_clip(y1)}..{_clip(y2)} px"
|
|
1586
|
+
else:
|
|
1587
|
+
extent = ""
|
|
1588
|
+
out.append(f'Canvas "{canvas.get("name", "")}": {len(nodes)} nodes ({", ".join(f"{v} {k}" for k, v in sorted(kinds.items()))}), '
|
|
1589
|
+
f'{len(edges)} connectors{extent}. Layout score {rep["score"]}/100.')
|
|
1590
|
+
out.append("Position is x,y then WxH in px, origin top-left, y grows downward.")
|
|
1591
|
+
budget = max(max_lines - len(out) - 1, 12)
|
|
1592
|
+
frames = [n for n in nodes if n.get("type") == "frame"]
|
|
1593
|
+
n_issue = min(len(rep["issues"]), max(4, budget // 6))
|
|
1594
|
+
n_edge = max(3, budget * 3 // 10)
|
|
1595
|
+
n_frame = min(len(frames), max(2, budget // 8))
|
|
1596
|
+
n_map = max(budget - n_issue - n_edge - n_frame - 3, 6)
|
|
1597
|
+
if frames:
|
|
1598
|
+
out.append("Frames:")
|
|
1599
|
+
for f in frames[:n_frame]:
|
|
1600
|
+
mem = [k for k, p in parent.items() if p == f["id"]]
|
|
1601
|
+
names = ", ".join(f'{k}' for k in mem[:10]) + (f" +{len(mem) - 10} more" if len(mem) > 10 else "")
|
|
1602
|
+
out.append(f" {_fmt(f)} holds {len(mem)}: {names or 'nothing'}")
|
|
1603
|
+
if len(frames) > n_frame:
|
|
1604
|
+
out.append(f" +{len(frames) - n_frame} more frames")
|
|
1605
|
+
flat = [n for n in nodes if n.get("type") != "frame"]
|
|
1606
|
+
if verbose:
|
|
1607
|
+
out.append("Nodes:")
|
|
1608
|
+
out += [f" {_fmt(n)} ({n.get('type')}){' in ' + parent[n['id']] if n['id'] in parent else ''}" for n in sorted(flat, key=_order_key)]
|
|
1609
|
+
elif flat:
|
|
1610
|
+
out.append("Map, reading order (id label x,y WxH):")
|
|
1611
|
+
out += _spatial_map(flat, parent, n_map)
|
|
1612
|
+
if edges:
|
|
1613
|
+
out.append("Connectors:")
|
|
1614
|
+
grouped: dict[str, list[str]] = {}
|
|
1615
|
+
for e in edges:
|
|
1616
|
+
tgt = e["target"] + (f' ({e["label"]})' if e.get("label") else "")
|
|
1617
|
+
grouped.setdefault(e["source"], []).append(tgt)
|
|
1618
|
+
lines = [f" {s} → {', '.join(ts)}" for s, ts in sorted(grouped.items(), key=lambda kv: (_y(by[kv[0]]), _x(by[kv[0]])) if kv[0] in by else (0, 0))]
|
|
1619
|
+
out += lines[:n_edge]
|
|
1620
|
+
if len(lines) > n_edge:
|
|
1621
|
+
out.append(f" +{len(lines) - n_edge} more sources")
|
|
1622
|
+
m = rep["metrics"]
|
|
1623
|
+
out.append(f'Metrics: overlap {m["overlap_area"]:g} px², crossings {m["edge_crossings"]}, alignment {m["alignment_score"]:g}, '
|
|
1624
|
+
f'whitespace {m["whitespace_ratio"]:g}.')
|
|
1625
|
+
if rep["issues"]:
|
|
1626
|
+
out.append("Top issues:")
|
|
1627
|
+
for i in rep["issues"][:n_issue]:
|
|
1628
|
+
out.append(f' [{i["severity"]}] {i["message"]}' + (" (fixable by tidy)" if i["fix"] else ""))
|
|
1629
|
+
if len(rep["issues"]) > n_issue:
|
|
1630
|
+
out.append(f' +{len(rep["issues"]) - n_issue} more; call doctor for all')
|
|
1631
|
+
else:
|
|
1632
|
+
out.append("No layout issues.")
|
|
1633
|
+
return "\n".join(out[: max(max_lines, 10)])
|
|
1634
|
+
|
|
1635
|
+
|
|
1636
|
+
def _spatial_map(flat: list[dict], parent: dict[str, str], limit: int) -> list[str]:
|
|
1637
|
+
"""Rows of nodes in reading order. Falls back to a 3 x 3 region map when there are too many rows."""
|
|
1638
|
+
rows: list[list[dict]] = []
|
|
1639
|
+
for n in sorted(flat, key=lambda n: ((_y(n) + _h(n) / 2), _x(n), str(n["id"]))):
|
|
1640
|
+
for r in rows:
|
|
1641
|
+
a = _box(r[0])
|
|
1642
|
+
b = _box(n)
|
|
1643
|
+
if min(a[3], b[3]) - max(a[1], b[1]) >= 0.5 * min(a[3] - a[1], b[3] - b[1]):
|
|
1644
|
+
r.append(n)
|
|
1645
|
+
break
|
|
1646
|
+
else:
|
|
1647
|
+
rows.append([n])
|
|
1648
|
+
per = 8
|
|
1649
|
+
|
|
1650
|
+
def line(prefix, ns):
|
|
1651
|
+
ns = sorted(ns, key=lambda n: (_x(n), str(n["id"])))
|
|
1652
|
+
txt = "; ".join(_fmt(n) + (f" in {parent[n['id']]}" if n["id"] in parent else "") for n in ns[:per])
|
|
1653
|
+
return f" {prefix}: {txt}" + (f" +{len(ns) - per} more" if len(ns) > per else "")
|
|
1654
|
+
if len(rows) <= limit:
|
|
1655
|
+
return [line(f"row {i + 1}", r) for i, r in enumerate(rows)]
|
|
1656
|
+
x1, y1 = min(_x(n) for n in flat), min(_y(n) for n in flat)
|
|
1657
|
+
x2, y2 = max(_x(n) + _w(n) for n in flat), max(_y(n) + _h(n) for n in flat)
|
|
1658
|
+
names = (("top", "middle", "bottom"), ("left", "center", "right"))
|
|
1659
|
+
cells: dict[tuple, list] = {}
|
|
1660
|
+
for n in flat:
|
|
1661
|
+
cx, cy = _x(n) + _w(n) / 2, _y(n) + _h(n) / 2
|
|
1662
|
+
r = min(2, int(3 * (cy - y1) / max(y2 - y1, 1)))
|
|
1663
|
+
c = min(2, int(3 * (cx - x1) / max(x2 - x1, 1)))
|
|
1664
|
+
cells.setdefault((r, c), []).append(n)
|
|
1665
|
+
return [line(f"{names[0][r]}-{names[1][c]}", ns) for (r, c), ns in sorted(cells.items())]
|
|
1666
|
+
|
|
1667
|
+
|
|
1668
|
+
# ---------------------------------------------------------------- render
|
|
1669
|
+
|
|
1670
|
+
_FILLS = {"query": ("#dbeafe", "#2563eb"), "table": ("#dcfce7", "#16a34a"), "note": ("#fef9c3", "#ca8a04"),
|
|
1671
|
+
"chart": ("#ede9fe", "#7c3aed"), "shape": ("#ffffff", "#1f2328"), "sticky": ("#fef9c3", "#ca8a04"),
|
|
1672
|
+
"frame": ("#f3f4f6", "#6b7280"), "placeholder": ("#ffedd5", "#ea580c"), "text": (None, None)}
|
|
1673
|
+
|
|
1674
|
+
|
|
1675
|
+
def _color(v: Any, default: Optional[str]) -> Optional[str]:
|
|
1676
|
+
if isinstance(v, str) and v.startswith("#") and len(v) in (4, 7) and all(c in "0123456789abcdefABCDEF" for c in v[1:]):
|
|
1677
|
+
return v
|
|
1678
|
+
return default
|
|
1679
|
+
|
|
1680
|
+
|
|
1681
|
+
def render_png(canvas: dict, width: int = 1200, highlight: Optional[Iterable[str]] = None) -> bytes:
|
|
1682
|
+
"""A schematic picture: boxes by type with ids and truncated labels, connectors with arrowheads, issue nodes outlined."""
|
|
1683
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
1684
|
+
|
|
1685
|
+
width = int(min(max(width, 200), 4000))
|
|
1686
|
+
nodes = _nodes(canvas)
|
|
1687
|
+
hi = set(highlight or [])
|
|
1688
|
+
pad = 30
|
|
1689
|
+
if nodes:
|
|
1690
|
+
x1, y1 = min(_box(n)[0] for n in nodes), min(_box(n)[1] for n in nodes)
|
|
1691
|
+
x2, y2 = max(_box(n)[2] for n in nodes), max(_box(n)[3] for n in nodes)
|
|
1692
|
+
else:
|
|
1693
|
+
x1 = y1 = 0.0
|
|
1694
|
+
x2 = y2 = 100.0
|
|
1695
|
+
scale = min((width - 2 * pad) / max(x2 - x1, 1), 1.5)
|
|
1696
|
+
height = int(min(max((y2 - y1) * scale + 2 * pad, 120), 4000))
|
|
1697
|
+
scale = min(scale, (height - 2 * pad) / max(y2 - y1, 1))
|
|
1698
|
+
|
|
1699
|
+
def tx(p):
|
|
1700
|
+
return (pad + (p[0] - x1) * scale, pad + (p[1] - y1) * scale)
|
|
1701
|
+
|
|
1702
|
+
def font(size):
|
|
1703
|
+
try:
|
|
1704
|
+
return ImageFont.load_default(size=size)
|
|
1705
|
+
except TypeError: # Pillow before 10.1 has one fixed size
|
|
1706
|
+
return ImageFont.load_default()
|
|
1707
|
+
img = Image.new("RGB", (width, height), "white")
|
|
1708
|
+
d = ImageDraw.Draw(img)
|
|
1709
|
+
# Labels scale with the boxes (the page's 14 px default), so a label the doctor says fits also fits the picture.
|
|
1710
|
+
f_lab, f_id = font(max(9, int(14 * scale))), font(9)
|
|
1711
|
+
by = {n["id"]: n for n in nodes}
|
|
1712
|
+
for n in sorted(nodes, key=lambda n: (_num(n.get("z"), -1 if n.get("type") == "frame" else 0), 0)):
|
|
1713
|
+
b = _box(n)
|
|
1714
|
+
a, c = tx((b[0], b[1])), tx((b[2], b[3]))
|
|
1715
|
+
fill, line = _FILLS.get(n.get("type"), ("#ffffff", "#1f2328"))
|
|
1716
|
+
fill, line = _color(n.get("fill"), fill), _color(n.get("stroke"), line)
|
|
1717
|
+
shape = n.get("shape") if n.get("type") == "shape" else None
|
|
1718
|
+
if n.get("type") == "text":
|
|
1719
|
+
pass
|
|
1720
|
+
elif shape == "ellipse":
|
|
1721
|
+
d.ellipse([a, c], fill=fill, outline=line, width=2)
|
|
1722
|
+
elif shape == "diamond":
|
|
1723
|
+
mx, my = (a[0] + c[0]) / 2, (a[1] + c[1]) / 2
|
|
1724
|
+
d.polygon([(mx, a[1]), (c[0], my), (mx, c[1]), (a[0], my)], fill=fill, outline=line)
|
|
1725
|
+
elif shape == "rounded":
|
|
1726
|
+
d.rounded_rectangle([a, c], radius=8, fill=fill, outline=line, width=2)
|
|
1727
|
+
else:
|
|
1728
|
+
d.rectangle([a, c], fill=fill, outline=line, width=2)
|
|
1729
|
+
text = _text_of(n)
|
|
1730
|
+
if n.get("type") == "placeholder":
|
|
1731
|
+
text = str((n.get("placeholder") or {}).get("name") or "")
|
|
1732
|
+
if n.get("type") == "frame":
|
|
1733
|
+
tpos = (a[0] + 6, a[1] + 3)
|
|
1734
|
+
else:
|
|
1735
|
+
tw = d.textlength(text, font=f_lab) if text else 0
|
|
1736
|
+
maxw = max(c[0] - a[0] - 8, 8)
|
|
1737
|
+
while text and tw > maxw and len(text) > 1:
|
|
1738
|
+
text = text.rstrip("…")[:-1] + "…"
|
|
1739
|
+
tw = d.textlength(text, font=f_lab)
|
|
1740
|
+
tpos = ((a[0] + c[0]) / 2 - tw / 2, (a[1] + c[1]) / 2 - 7)
|
|
1741
|
+
if text:
|
|
1742
|
+
d.text(tpos, text, fill="#111827", font=f_lab)
|
|
1743
|
+
d.text((a[0] + 3, a[1] + 1 if n.get("type") != "frame" else c[1] - 11), str(n["id"]), fill="#6b7280", font=f_id)
|
|
1744
|
+
if n["id"] in hi:
|
|
1745
|
+
d.rectangle([(a[0] - 3, a[1] - 3), (c[0] + 3, c[1] + 3)], outline="#dc2626", width=3)
|
|
1746
|
+
by_edge = {e["id"]: e for e in _edges(canvas)}
|
|
1747
|
+
for e, pts in _polylines(canvas):
|
|
1748
|
+
col = _color(e.get("color"), "#4b5563")
|
|
1749
|
+
sp = [tx(p) for p in pts]
|
|
1750
|
+
d.line(sp, fill=col, width=2)
|
|
1751
|
+
for end, other in ((sp[-1], sp[-2]), (sp[0], sp[1])):
|
|
1752
|
+
if (end is sp[-1] and e.get("arrow_end", True)) or (end is sp[0] and e.get("arrow_start", False)):
|
|
1753
|
+
ang = math.atan2(end[1] - other[1], end[0] - other[0])
|
|
1754
|
+
d.polygon([end, (end[0] - 9 * math.cos(ang - 0.4), end[1] - 9 * math.sin(ang - 0.4)),
|
|
1755
|
+
(end[0] - 9 * math.cos(ang + 0.4), end[1] - 9 * math.sin(ang + 0.4))], fill=col)
|
|
1756
|
+
f_edge = font(max(8, int(12 * scale)))
|
|
1757
|
+
for eid, (bx1, by1, bx2, by2) in edge_label_boxes(canvas).items():
|
|
1758
|
+
a, c = tx((bx1, by1)), tx((bx2, by2))
|
|
1759
|
+
d.rectangle([a, c], fill="white", outline="#9ca3af")
|
|
1760
|
+
d.multiline_text(((a[0] + c[0]) / 2, (a[1] + c[1]) / 2), str(by_edge[eid].get("label")), fill="#374151", font=f_edge,
|
|
1761
|
+
anchor="mm", align="center")
|
|
1762
|
+
buf = io.BytesIO()
|
|
1763
|
+
img.save(buf, "PNG")
|
|
1764
|
+
return buf.getvalue()
|