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.
@@ -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()