codefig 0.17.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.
- codefig/__init__.py +210 -0
- codefig/_validate.py +12 -0
- codefig/autowrap.py +277 -0
- codefig/charts.py +1806 -0
- codefig/code.py +602 -0
- codefig/compose.py +374 -0
- codefig/constraints.py +1571 -0
- codefig/dim.py +214 -0
- codefig/edge_align.py +231 -0
- codefig/edges.py +2824 -0
- codefig/export.py +204 -0
- codefig/figure.py +1923 -0
- codefig/fills.py +146 -0
- codefig/flows.py +102 -0
- codefig/glyphs.py +395 -0
- codefig/icon.py +288 -0
- codefig/icons/LICENSE-tabler-icons.txt +21 -0
- codefig/icons/__init__.py +101 -0
- codefig/icons/tabler.json +1 -0
- codefig/legend.py +483 -0
- codefig/lint.py +1737 -0
- codefig/lint_geom.py +528 -0
- codefig/node.py +864 -0
- codefig/notices.py +34 -0
- codefig/overlaps.py +392 -0
- codefig/policy.py +137 -0
- codefig/primitives.py +2721 -0
- codefig/py.typed +0 -0
- codefig/regions.py +727 -0
- codefig/render_svg.py +512 -0
- codefig/routing.py +932 -0
- codefig/shapes.py +642 -0
- codefig/solids.py +514 -0
- codefig/steps.py +203 -0
- codefig/styles.py +893 -0
- codefig/textutil.py +889 -0
- codefig/timeline.py +1288 -0
- codefig/tree.py +277 -0
- codefig/venues.py +93 -0
- codefig-0.17.0.dist-info/METADATA +542 -0
- codefig-0.17.0.dist-info/RECORD +44 -0
- codefig-0.17.0.dist-info/WHEEL +5 -0
- codefig-0.17.0.dist-info/licenses/LICENSE +21 -0
- codefig-0.17.0.dist-info/top_level.txt +1 -0
codefig/notices.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""v0.12.0: draw-time notices (labels the engine shortened or dropped).
|
|
2
|
+
|
|
3
|
+
Draw code calls :func:`note` when it ellipsizes a label or skips one for lack of room;
|
|
4
|
+
the linter resets the list before its instrumented render and reports what was noted
|
|
5
|
+
as CF-TXT-011, so nothing disappears silently.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, List, Tuple
|
|
10
|
+
|
|
11
|
+
_NOTES: List[Tuple[Any, str, str, str]] = []
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def note(owner: Any, text: str, why: str, level: str = "warn") -> None:
|
|
15
|
+
"""Record that ``owner`` shortened, moved or dropped ``text``.
|
|
16
|
+
|
|
17
|
+
``why``: short reason; ``level``: ``"warn"`` (shortened / not drawn) or ``"info"``
|
|
18
|
+
(drawn, but at a fallback position).
|
|
19
|
+
"""
|
|
20
|
+
key = (id(owner), str(text), why)
|
|
21
|
+
if any((id(o), t, w) == key for o, t, w, _l in _NOTES):
|
|
22
|
+
return
|
|
23
|
+
_NOTES.append((owner, str(text), why, level))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def reset() -> None:
|
|
27
|
+
_NOTES.clear()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def drained() -> List[Tuple[Any, str, str, str]]:
|
|
31
|
+
"""Notes recorded since the last :func:`reset` (cleared on read)."""
|
|
32
|
+
out = list(_NOTES)
|
|
33
|
+
_NOTES.clear()
|
|
34
|
+
return out
|
codefig/overlaps.py
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
"""Global overlap detection and resolution inside one panel.
|
|
2
|
+
|
|
3
|
+
Any two *visible* nodes that are not ancestor/descendant of each other (boxes,
|
|
4
|
+
texts, matrices, strips, dot grids, braces, framed groups, and place()
|
|
5
|
+
annotations) may not overlap by more than ``tol`` px on both axes. Layout-only
|
|
6
|
+
containers (Row/Col/Stack/Grid/Diagram), Spacers, arrows and CircleHighlight /
|
|
7
|
+
Callout rings (intentional overlays) are ignored, as are nodes listed in an
|
|
8
|
+
``allow_overlap(...)`` constraint.
|
|
9
|
+
|
|
10
|
+
Policies (``Figure(overlap=...)`` / ``Panel(overlap=...)``):
|
|
11
|
+
|
|
12
|
+
* ``"resolve"`` (default): each overlapping pair gets a ``no_overlap`` constraint
|
|
13
|
+
along the axis of least penetration and the panel is re-solved (≤ 6 rounds);
|
|
14
|
+
anything still overlapping emits an :class:`OverlapWarning`.
|
|
15
|
+
* ``"warn"``: report only (OverlapWarning).
|
|
16
|
+
* ``"error"``: raise :class:`OverlapError`.
|
|
17
|
+
* ``"ignore"``: skip the check.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
|
|
24
|
+
|
|
25
|
+
from .constraints import Constraint, node_name, no_overlap, walk
|
|
26
|
+
|
|
27
|
+
POLICIES = ("resolve", "warn", "error", "ignore")
|
|
28
|
+
|
|
29
|
+
_VISIBLE = {"Box", "Text", "Ellipsis", "Matrix", "DotGrid", "Strip", "Brace", "Group",
|
|
30
|
+
"Badge", "BarChart", "VectorFan", "Swatch", "Legend"}
|
|
31
|
+
# intentional overlays: rings / callouts sit on top of what they highlight
|
|
32
|
+
_OVERLAY_TYPES = {"CircleHighlight", "Callout"}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _unit() -> str:
|
|
37
|
+
from .styles import length_unit
|
|
38
|
+
|
|
39
|
+
return length_unit()
|
|
40
|
+
|
|
41
|
+
class OverlapError(ValueError):
|
|
42
|
+
"""Raised with overlap='error' when visible nodes overlap."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class OverlapWarning(UserWarning):
|
|
46
|
+
"""Emitted when visible nodes overlap (overlap='warn', or unresolved)."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _node_context(node: Any) -> str:
|
|
50
|
+
"""v0.13.0: where a crossed node is — its nearest named container and its box —
|
|
51
|
+
so ``Task('…')`` or an unnamed Box can be found without guessing."""
|
|
52
|
+
parts = []
|
|
53
|
+
p = getattr(node, "parent", None)
|
|
54
|
+
while p is not None:
|
|
55
|
+
nm = node_name(p)
|
|
56
|
+
if "@" not in nm:
|
|
57
|
+
parts.append(f"inside {nm}")
|
|
58
|
+
break
|
|
59
|
+
p = getattr(p, "parent", None)
|
|
60
|
+
g = getattr(node, "geom", None)
|
|
61
|
+
if g is not None:
|
|
62
|
+
parts.append(f"at x {g.x:.0f}–{g.x + g.w:.0f}, y {g.y:.0f}–{g.y + g.h:.0f}{_unit()}")
|
|
63
|
+
return f" ({', '.join(parts)})" if parts else ""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class Overlap:
|
|
68
|
+
a: Any
|
|
69
|
+
b: Any
|
|
70
|
+
dx: float # penetration along x (px, pre-scale panel units)
|
|
71
|
+
dy: float
|
|
72
|
+
panel: str = ""
|
|
73
|
+
kind: str = "node" # node | edge-node | head-text | label-node | label-text | label-label
|
|
74
|
+
# | step-node | step-text | step-label (edge step badges, v0.5.0)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def axis(self) -> str:
|
|
79
|
+
"""Axis of least penetration (the cheap way out)."""
|
|
80
|
+
return "x" if self.dx <= self.dy else "y"
|
|
81
|
+
|
|
82
|
+
def __str__(self) -> str:
|
|
83
|
+
where = f"panel ({self.panel}): " if self.panel else ""
|
|
84
|
+
if self.kind == "edge-node":
|
|
85
|
+
return (f"{where}{edge_name(self.a)} crosses hard node {node_name(self.b)}"
|
|
86
|
+
f"{_node_context(self.b)}")
|
|
87
|
+
if self.kind == "head-text":
|
|
88
|
+
return (f"{where}arrowhead of {edge_name(self.a)} lands on text {node_name(self.b)} "
|
|
89
|
+
f"by {self.dx:.1f}×{self.dy:.1f}{_unit()}")
|
|
90
|
+
if self.kind.startswith("step-"):
|
|
91
|
+
other = (f"label/step of {edge_name(self.b)}" if self.kind == "step-label"
|
|
92
|
+
else node_name(self.b))
|
|
93
|
+
return (f"{where}step badge {self.a.step.text!r} on {edge_name(self.a)} overlaps {other} "
|
|
94
|
+
f"by {self.dx:.1f}×{self.dy:.1f}{_unit()}")
|
|
95
|
+
if self.kind.startswith("label-"):
|
|
96
|
+
other = (f"label {self.b.label!r}" if self.kind == "label-label"
|
|
97
|
+
else node_name(self.b))
|
|
98
|
+
return (f"{where}label {self.a.label!r} of {edge_name(self.a)} overlaps {other} "
|
|
99
|
+
f"by {self.dx:.1f}×{self.dy:.1f}{_unit()}")
|
|
100
|
+
return (f"{where}{node_name(self.a)} overlaps {node_name(self.b)} "
|
|
101
|
+
f"by {self.dx:.1f}×{self.dy:.1f}{_unit()}")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def edge_name(e: Any) -> str:
|
|
105
|
+
try:
|
|
106
|
+
return f"edge {node_name(e.src.node)}→{node_name(e.dst.node)}"
|
|
107
|
+
except Exception: # pragma: no cover
|
|
108
|
+
return "edge"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def check_policy(p: Optional[str]) -> Optional[str]:
|
|
112
|
+
if p is None:
|
|
113
|
+
return None
|
|
114
|
+
p = str(p).lower()
|
|
115
|
+
if p not in POLICIES:
|
|
116
|
+
raise ValueError(f"overlap must be one of {POLICIES}, got {p!r}")
|
|
117
|
+
return p
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _visible(n: Any, overlay_ids: set) -> bool:
|
|
121
|
+
"""Takes part in node-vs-node checks: any role except none (see codefig.policy)."""
|
|
122
|
+
from .policy import role_of
|
|
123
|
+
|
|
124
|
+
if getattr(n, "allow_overlap", False):
|
|
125
|
+
return False
|
|
126
|
+
if getattr(n, "_cf_guide", False): # timeline lane bands / marker lines / axis
|
|
127
|
+
return False
|
|
128
|
+
role = role_of(n)
|
|
129
|
+
if id(n) in overlay_ids:
|
|
130
|
+
return getattr(n, "_cf_obstacle", True) and role != "none"
|
|
131
|
+
if type(n).__name__ in _OVERLAY_TYPES:
|
|
132
|
+
return False
|
|
133
|
+
return role in ("hard", "soft", "background")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _contains(outer: Any, inner: Any, eps: float = 0.5) -> bool:
|
|
137
|
+
"""A framed Group (or any background-role node) fully containing another node
|
|
138
|
+
(e.g. an annotation hosted inside it) is nesting, not overlap."""
|
|
139
|
+
from .policy import role_of
|
|
140
|
+
|
|
141
|
+
if type(outer).__name__ != "Group" and role_of(outer) != "background":
|
|
142
|
+
return False
|
|
143
|
+
go, gi = outer.geom, inner.geom
|
|
144
|
+
return (gi.x >= go.x - eps and gi.y >= go.y - eps and gi.x + gi.w <= go.x + go.w + eps
|
|
145
|
+
and gi.y + gi.h <= go.y + go.h + eps)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def find_overlaps(root: Any, overlays: Sequence[Any] = (), constraints: Sequence[Constraint] = (),
|
|
149
|
+
tol: float = 0.5) -> List[Overlap]:
|
|
150
|
+
"""All topmost overlapping pairs of visible nodes under ``root`` (+ overlays)."""
|
|
151
|
+
parent: Dict[int, Any] = {}
|
|
152
|
+
nodes: List[Any] = []
|
|
153
|
+
for top in [root] + list(overlays):
|
|
154
|
+
for n in walk(top):
|
|
155
|
+
nodes.append(n)
|
|
156
|
+
for k in n.children():
|
|
157
|
+
parent[id(k)] = n
|
|
158
|
+
overlay_ids = {id(o) for o in overlays}
|
|
159
|
+
|
|
160
|
+
free_all: set = set()
|
|
161
|
+
free_pairs: List[set] = []
|
|
162
|
+
for c in constraints:
|
|
163
|
+
if c.kind == "allow_overlap":
|
|
164
|
+
ids = {id(n) for n in c.subjects}
|
|
165
|
+
if len(ids) == 1:
|
|
166
|
+
free_all |= ids
|
|
167
|
+
else:
|
|
168
|
+
free_pairs.append(ids)
|
|
169
|
+
|
|
170
|
+
def chain(n: Any) -> List[int]:
|
|
171
|
+
out = []
|
|
172
|
+
while n is not None:
|
|
173
|
+
out.append(id(n))
|
|
174
|
+
n = parent.get(id(n))
|
|
175
|
+
return out
|
|
176
|
+
|
|
177
|
+
chains = {id(n): chain(n) for n in nodes}
|
|
178
|
+
cand = [n for n in nodes if _visible(n, overlay_ids)]
|
|
179
|
+
|
|
180
|
+
def freed(a: Any, b: Any) -> bool:
|
|
181
|
+
ca, cb = set(chains[id(a)]), set(chains[id(b)])
|
|
182
|
+
if ca & free_all or cb & free_all:
|
|
183
|
+
return True
|
|
184
|
+
return any((ca & ids) and (cb & ids) for ids in free_pairs)
|
|
185
|
+
|
|
186
|
+
raw: List[Overlap] = []
|
|
187
|
+
for i, a in enumerate(cand):
|
|
188
|
+
ga = a.geom
|
|
189
|
+
if ga.w <= 1 or ga.h <= 1:
|
|
190
|
+
continue
|
|
191
|
+
for b in cand[i + 1:]:
|
|
192
|
+
gb = b.geom
|
|
193
|
+
if gb.w <= 1 or gb.h <= 1:
|
|
194
|
+
continue
|
|
195
|
+
if id(a) in chains[id(b)] or id(b) in chains[id(a)]:
|
|
196
|
+
continue
|
|
197
|
+
if _contains(a, b) or _contains(b, a):
|
|
198
|
+
continue
|
|
199
|
+
dx = min(ga.x + ga.w, gb.x + gb.w) - max(ga.x, gb.x)
|
|
200
|
+
dy = min(ga.y + ga.h, gb.y + gb.h) - max(ga.y, gb.y)
|
|
201
|
+
if dx > tol and dy > tol and not freed(a, b):
|
|
202
|
+
raw.append(Overlap(a, b, dx, dy))
|
|
203
|
+
# keep only topmost pairs: drop (a, b) if an ancestor pair also overlaps
|
|
204
|
+
keys = {(id(o.a), id(o.b)) for o in raw} | {(id(o.b), id(o.a)) for o in raw}
|
|
205
|
+
out = []
|
|
206
|
+
for o in raw:
|
|
207
|
+
ca, cb = chains[id(o.a)], chains[id(o.b)]
|
|
208
|
+
shadowed = any((x, y) in keys and (x, y) != (id(o.a), id(o.b))
|
|
209
|
+
for x in ca for y in cb)
|
|
210
|
+
if not shadowed:
|
|
211
|
+
out.append(o)
|
|
212
|
+
return out
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _isect(a: Tuple[float, float, float, float], b: Tuple[float, float, float, float]
|
|
216
|
+
) -> Tuple[float, float]:
|
|
217
|
+
dx = min(a[0] + a[2], b[0] + b[2]) - max(a[0], b[0])
|
|
218
|
+
dy = min(a[1] + a[3], b[1] + b[3]) - max(a[1], b[1])
|
|
219
|
+
return dx, dy
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _head_box(tip: Tuple[float, float], prev: Tuple[float, float], length: float,
|
|
223
|
+
width: float) -> Tuple[float, float, float, float]:
|
|
224
|
+
"""Axis-aligned box of an arrowhead whose tip is ``tip`` (coming from ``prev``)."""
|
|
225
|
+
import math
|
|
226
|
+
|
|
227
|
+
dx, dy = tip[0] - prev[0], tip[1] - prev[1]
|
|
228
|
+
L = math.hypot(dx, dy) or 1.0
|
|
229
|
+
ux, uy = dx / L, dy / L
|
|
230
|
+
bx, by = tip[0] - ux * length, tip[1] - uy * length
|
|
231
|
+
hw = width / 2
|
|
232
|
+
xs = [tip[0], bx - uy * hw, bx + uy * hw]
|
|
233
|
+
ys = [tip[1], by + ux * hw, by - ux * hw]
|
|
234
|
+
return min(xs), min(ys), max(xs) - min(xs), max(ys) - min(ys)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def find_edge_issues(diagram: Any, overlays: Sequence[Any] = (), constraints: Sequence[Any] = (),
|
|
238
|
+
tol: float = 0.5) -> List[Overlap]:
|
|
239
|
+
"""Layering errors for a Diagram's routed edges and labels (codefig.policy):
|
|
240
|
+
|
|
241
|
+
* an edge path crossing a **hard** node that is not one of its endpoints (nor an
|
|
242
|
+
ancestor / descendant of one) → ``edge-node``;
|
|
243
|
+
* an edge label overlapping a hard node / soft text / another label → ``label-*``.
|
|
244
|
+
|
|
245
|
+
Edges passing under soft text or labels, and across background groups, are fine.
|
|
246
|
+
"""
|
|
247
|
+
from .edges import _poly_hits_rect
|
|
248
|
+
from .policy import role_of
|
|
249
|
+
|
|
250
|
+
lay = diagram.layout_edges()
|
|
251
|
+
parent: Dict[int, Any] = {}
|
|
252
|
+
hard: List[Any] = []
|
|
253
|
+
soft: List[Any] = []
|
|
254
|
+
free = set()
|
|
255
|
+
for c in constraints:
|
|
256
|
+
if getattr(c, "kind", "") == "allow_overlap":
|
|
257
|
+
free |= {id(n) for n in c.subjects}
|
|
258
|
+
for top in [diagram.content] + list(overlays):
|
|
259
|
+
for n in walk(top):
|
|
260
|
+
for k in n.children():
|
|
261
|
+
parent[id(k)] = n
|
|
262
|
+
if getattr(n, "allow_overlap", False) or id(n) in free:
|
|
263
|
+
continue
|
|
264
|
+
if id(n) in {id(o) for o in overlays} and not getattr(n, "_cf_obstacle", True):
|
|
265
|
+
continue
|
|
266
|
+
r = role_of(n)
|
|
267
|
+
g = n.geom
|
|
268
|
+
if g.w <= 1 or g.h <= 1:
|
|
269
|
+
continue
|
|
270
|
+
if r == "hard":
|
|
271
|
+
hard.append(n)
|
|
272
|
+
elif r == "soft":
|
|
273
|
+
soft.append(n)
|
|
274
|
+
|
|
275
|
+
def family(nodes: Iterable[Any]) -> set:
|
|
276
|
+
out = set()
|
|
277
|
+
for n in nodes:
|
|
278
|
+
m = n
|
|
279
|
+
while m is not None: # ancestors
|
|
280
|
+
out.add(id(m))
|
|
281
|
+
m = parent.get(id(m))
|
|
282
|
+
for d in walk(n): # descendants
|
|
283
|
+
out.add(id(d))
|
|
284
|
+
return out
|
|
285
|
+
|
|
286
|
+
issues: List[Overlap] = []
|
|
287
|
+
shrink = 1.5
|
|
288
|
+
for e, pts in lay["routed"]:
|
|
289
|
+
fk = getattr(e, "_fork", None)
|
|
290
|
+
ends = [e.src.node, e.dst.node]
|
|
291
|
+
if fk is not None: # a fork piece is exempt only for the hub and its own leaf
|
|
292
|
+
ends = [fk._node(fk.hub), e.src.node, e.dst.node]
|
|
293
|
+
if getattr(e, "around", None) is not None:
|
|
294
|
+
ends.append(e.around)
|
|
295
|
+
fam = family(ends)
|
|
296
|
+
# v0.13.0: a Guide is meant to pass over things — no runtime "crosses hard node"
|
|
297
|
+
for n in (() if getattr(e, "_cf_guide_edge", False) else hard):
|
|
298
|
+
if id(n) in fam:
|
|
299
|
+
continue
|
|
300
|
+
g = n.geom
|
|
301
|
+
r = (g.x + shrink, g.y + shrink, g.w - 2 * shrink, g.h - 2 * shrink)
|
|
302
|
+
if r[2] > 0 and r[3] > 0 and _poly_hits_rect(pts, r):
|
|
303
|
+
issues.append(Overlap(e, n, 0.0, 0.0, kind="edge-node"))
|
|
304
|
+
# arrowheads are never knocked out, so a head on soft text is an error
|
|
305
|
+
st = e.resolved(diagram.theme)
|
|
306
|
+
for hd, tip, prev in ((st.end_head, pts[-1], pts[-2]), (st.start_head, pts[0], pts[1])):
|
|
307
|
+
if hd is None or not hd.visible:
|
|
308
|
+
continue
|
|
309
|
+
hb = _head_box(tip, prev, hd.length, hd.width)
|
|
310
|
+
for n in soft:
|
|
311
|
+
if id(n) in fam:
|
|
312
|
+
continue
|
|
313
|
+
ib = n.ink_box() if hasattr(n, "ink_box") else (n.geom.x, n.geom.y, n.geom.w, n.geom.h)
|
|
314
|
+
dx, dy = _isect(hb, ib)
|
|
315
|
+
if dx > tol and dy > tol:
|
|
316
|
+
issues.append(Overlap(e, n, dx, dy, kind="head-text"))
|
|
317
|
+
|
|
318
|
+
labels = lay["labels"]
|
|
319
|
+
for i, (e, _pts, geo) in enumerate(labels):
|
|
320
|
+
box = geo[0]
|
|
321
|
+
for n in hard:
|
|
322
|
+
g = n.geom
|
|
323
|
+
dx, dy = _isect(box, (g.x, g.y, g.w, g.h))
|
|
324
|
+
if dx > tol and dy > tol:
|
|
325
|
+
issues.append(Overlap(e, n, dx, dy, kind="label-node"))
|
|
326
|
+
for n in soft:
|
|
327
|
+
ib = n.ink_box() if hasattr(n, "ink_box") else (n.geom.x, n.geom.y, n.geom.w, n.geom.h)
|
|
328
|
+
dx, dy = _isect(box, ib)
|
|
329
|
+
if dx > tol and dy > tol:
|
|
330
|
+
issues.append(Overlap(e, n, dx, dy, kind="label-text"))
|
|
331
|
+
for e2, _p2, geo2 in labels[i + 1:]:
|
|
332
|
+
dx, dy = _isect(box, geo2[0])
|
|
333
|
+
if dx > tol and dy > tol:
|
|
334
|
+
issues.append(Overlap(e, e2, dx, dy, kind="label-label"))
|
|
335
|
+
# step badges on edges (①): like labels — no hard node, soft text, label or badge
|
|
336
|
+
steps = lay.get("steps", [])
|
|
337
|
+
for i, (e, box) in enumerate(steps):
|
|
338
|
+
for n in hard:
|
|
339
|
+
g = n.geom
|
|
340
|
+
dx, dy = _isect(box, (g.x, g.y, g.w, g.h))
|
|
341
|
+
if dx > tol and dy > tol:
|
|
342
|
+
issues.append(Overlap(e, n, dx, dy, kind="step-node"))
|
|
343
|
+
for n in soft:
|
|
344
|
+
ib = n.ink_box() if hasattr(n, "ink_box") else (n.geom.x, n.geom.y, n.geom.w, n.geom.h)
|
|
345
|
+
dx, dy = _isect(box, ib)
|
|
346
|
+
if dx > tol and dy > tol:
|
|
347
|
+
issues.append(Overlap(e, n, dx, dy, kind="step-text"))
|
|
348
|
+
for e2, _p2, geo2 in labels:
|
|
349
|
+
dx, dy = _isect(box, geo2[0])
|
|
350
|
+
if dx > tol and dy > tol:
|
|
351
|
+
issues.append(Overlap(e, e2, dx, dy, kind="step-label"))
|
|
352
|
+
for e2, box2 in steps[i + 1:]:
|
|
353
|
+
dx, dy = _isect(box, box2)
|
|
354
|
+
if dx > tol and dy > tol:
|
|
355
|
+
issues.append(Overlap(e, e2, dx, dy, kind="step-label"))
|
|
356
|
+
return issues
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def resolution_for(o: Overlap, overlays: Sequence[Any] = (), margin: float = 2.0,
|
|
360
|
+
strength: str = "required") -> Constraint:
|
|
361
|
+
"""A no_overlap constraint that separates the pair along its cheapest axis.
|
|
362
|
+
The annotation (if any) stays; the other node is pushed."""
|
|
363
|
+
ax = o.axis
|
|
364
|
+
a, b = o.a, o.b
|
|
365
|
+
ga, gb = a.geom, b.geom
|
|
366
|
+
ca = ga.x + ga.w / 2 if ax == "x" else ga.y + ga.h / 2
|
|
367
|
+
cb = gb.x + gb.w / 2 if ax == "x" else gb.y + gb.h / 2
|
|
368
|
+
first, second = (a, b) if ca <= cb else (b, a)
|
|
369
|
+
c = no_overlap(first, second, ax, margin=margin, strength=strength)
|
|
370
|
+
ov = {id(x) for x in overlays}
|
|
371
|
+
# subject = the mover; prefer moving the non-annotation
|
|
372
|
+
if id(second) in ov and id(first) not in ov:
|
|
373
|
+
c.subjects, c.refs = [first], [second]
|
|
374
|
+
return c
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def emit(found: Iterable[Overlap], policy: str) -> None:
|
|
378
|
+
import warnings
|
|
379
|
+
|
|
380
|
+
found = list(found)
|
|
381
|
+
if not found or policy == "ignore":
|
|
382
|
+
return
|
|
383
|
+
msg = "; ".join(str(o) for o in found)
|
|
384
|
+
what = ("overlapping nodes" if all(o.kind == "node" for o in found)
|
|
385
|
+
else "layering violations")
|
|
386
|
+
if policy == "error":
|
|
387
|
+
raise OverlapError(f"{what}: {msg}")
|
|
388
|
+
warnings.warn(f"{what}: {msg}", OverlapWarning, stacklevel=4)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
__all__ = ["Overlap", "OverlapError", "OverlapWarning", "find_overlaps", "find_edge_issues",
|
|
392
|
+
"resolution_for", "POLICIES"]
|
codefig/policy.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Layering / overlap policy: who may cover whom.
|
|
2
|
+
|
|
3
|
+
Every element has one *role* (``obstacle=``). Authors rarely set it — the class
|
|
4
|
+
default is right almost always:
|
|
5
|
+
|
|
6
|
+
============= ===================================================== =====================
|
|
7
|
+
role routing (edges …) check_overlaps()
|
|
8
|
+
============= ===================================================== =====================
|
|
9
|
+
``"hard"`` route *around* it; never drawn across it edge across it → error
|
|
10
|
+
``"soft"`` may pass *under* it (mild preference to avoid); edge under it: fine;
|
|
11
|
+
the text knocks the line out (clean gap) text-on-text → error
|
|
12
|
+
``"background"`` cross freely (a small preference to stay outside) edges: fine; nesting fine
|
|
13
|
+
``"none"`` invisible to routing and overlap checks ignored
|
|
14
|
+
============= ===================================================== =====================
|
|
15
|
+
|
|
16
|
+
Defaults — hard: Box, Matrix (TokenStrip / Table / heatmaps), Badge, BarChart,
|
|
17
|
+
VectorFan, Swatch, DotGrid, Strip, Brace, Legend, CircleHighlight/Callout, and (v0.4.0)
|
|
18
|
+
every shape (Ellipse, Circle, OpCircle, Cylinder, Document, Trapezoid, Parallelogram,
|
|
19
|
+
Hexagon, Diamond, ChatBubble, BlockArrow — Box subclasses), Icon, Image. Stacked
|
|
20
|
+
inherits its front card's role (hard for a Box, background for a Group).
|
|
21
|
+
v0.5.0 hard: Step badges (on boxes and edges), CodeBlock, Timeline Task bars and
|
|
22
|
+
TimeSpan braces / brackets / dims.
|
|
23
|
+
Soft: Text, Ellipsis, edge labels, place() text annotations, Repeat's "× N" label,
|
|
24
|
+
Dim / Timeline labels (lane headers, tick / marker / span labels).
|
|
25
|
+
Background: Group, Repeat's frame (and the panel), Lanes / Lane. A background node
|
|
26
|
+
that fully contains another is nesting, not overlap. Timeline lane bands, marker
|
|
27
|
+
lines and the axis line are *guides* (``_cf_guide``): background for routing and
|
|
28
|
+
skipped by check_overlaps(). Layout-only containers (Row/Col/Series/Tree/Timeline
|
|
29
|
+
body/…) take no part.
|
|
30
|
+
|
|
31
|
+
Override per element (``Text(..., obstacle="hard")``, ``Group(..., obstacle="none")``,
|
|
32
|
+
``Text(..., knockout=False)``) or per figure (``Figure(roles={"Text": "hard"},
|
|
33
|
+
knockout=False)``). ``obstacle=True`` means "hard", ``False`` means "none".
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import contextlib
|
|
39
|
+
from typing import Any, Dict, Iterator, Mapping, Optional
|
|
40
|
+
|
|
41
|
+
ROLES = ("hard", "soft", "background", "none")
|
|
42
|
+
|
|
43
|
+
_DEFAULT_ROLES: Dict[str, str] = {
|
|
44
|
+
"Box": "hard",
|
|
45
|
+
"Matrix": "hard",
|
|
46
|
+
"Badge": "hard",
|
|
47
|
+
"BarChart": "hard",
|
|
48
|
+
"VectorFan": "hard",
|
|
49
|
+
"Swatch": "hard",
|
|
50
|
+
"DotGrid": "hard",
|
|
51
|
+
"Strip": "hard",
|
|
52
|
+
"Brace": "hard",
|
|
53
|
+
"Legend": "hard",
|
|
54
|
+
"CircleHighlight": "hard",
|
|
55
|
+
"Callout": "hard",
|
|
56
|
+
"Icon": "hard",
|
|
57
|
+
"Image": "hard",
|
|
58
|
+
"Stacked": "hard", # overridden per instance: follows the front card's role
|
|
59
|
+
"Repeat": "background",
|
|
60
|
+
"Text": "soft",
|
|
61
|
+
"Ellipsis": "soft",
|
|
62
|
+
"Group": "background",
|
|
63
|
+
# v0.5.0
|
|
64
|
+
"Step": "hard",
|
|
65
|
+
"CodeBlock": "hard",
|
|
66
|
+
"TimeSpan": "hard",
|
|
67
|
+
"Lanes": "background",
|
|
68
|
+
"Lane": "background",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
_ALIASES = {True: "hard", False: "none", "solid": "hard", "coverable": "soft",
|
|
72
|
+
"transparent": "none", "free": "none", "bg": "background"}
|
|
73
|
+
|
|
74
|
+
_overrides: Dict[str, str] = {}
|
|
75
|
+
_knockout_default: bool = True
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def normalize_role(v: Any) -> Optional[str]:
|
|
79
|
+
if v is None:
|
|
80
|
+
return None
|
|
81
|
+
if isinstance(v, bool):
|
|
82
|
+
return _ALIASES[v]
|
|
83
|
+
s = str(v).lower()
|
|
84
|
+
s = _ALIASES.get(s, s)
|
|
85
|
+
if s not in ROLES:
|
|
86
|
+
raise ValueError(f"obstacle must be one of {ROLES} (or True/False), got {v!r}")
|
|
87
|
+
return s
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def default_role(node: Any) -> Optional[str]:
|
|
91
|
+
"""Class default (MRO aware, so subclasses inherit), after figure overrides."""
|
|
92
|
+
for cls in type(node).__mro__:
|
|
93
|
+
name = cls.__name__
|
|
94
|
+
if name in _overrides:
|
|
95
|
+
return _overrides[name]
|
|
96
|
+
if name in _DEFAULT_ROLES:
|
|
97
|
+
return _DEFAULT_ROLES[name]
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def role_of(node: Any) -> Optional[str]:
|
|
102
|
+
"""Effective role of ``node`` (None → layout-only, takes no part)."""
|
|
103
|
+
explicit = getattr(node, "_cf_obstacle_role", None) # set by place(obstacle=…)
|
|
104
|
+
if explicit is None:
|
|
105
|
+
explicit = normalize_role(getattr(node, "obstacle", None))
|
|
106
|
+
return explicit if explicit is not None else default_role(node)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def knockout_of(node: Any) -> bool:
|
|
110
|
+
k = getattr(node, "knockout", None)
|
|
111
|
+
return _knockout_default if k is None else bool(k)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def knockout_enabled(theme: Any = None) -> bool:
|
|
115
|
+
"""Figure-level knockout default (``use_policy``), further gated by ``theme.knockout``."""
|
|
116
|
+
if not _knockout_default:
|
|
117
|
+
return False
|
|
118
|
+
return bool(getattr(theme, "knockout", True)) if theme is not None else True
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@contextlib.contextmanager
|
|
122
|
+
def use_policy(roles: Optional[Mapping[str, Any]] = None,
|
|
123
|
+
knockout: Optional[bool] = None) -> Iterator[None]:
|
|
124
|
+
"""Figure-level defaults (applied while a Figure lays out / renders)."""
|
|
125
|
+
global _overrides, _knockout_default
|
|
126
|
+
old, old_k = dict(_overrides), _knockout_default
|
|
127
|
+
if roles:
|
|
128
|
+
_overrides.update({k: normalize_role(v) for k, v in roles.items()}) # type: ignore[misc]
|
|
129
|
+
if knockout is not None:
|
|
130
|
+
_knockout_default = bool(knockout)
|
|
131
|
+
try:
|
|
132
|
+
yield
|
|
133
|
+
finally:
|
|
134
|
+
_overrides, _knockout_default = old, old_k
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
POLICY_TABLE = __doc__
|