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/figure.py
ADDED
|
@@ -0,0 +1,1923 @@
|
|
|
1
|
+
"""Figure and Panel — top-level composition API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import os as _os
|
|
7
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
|
|
8
|
+
|
|
9
|
+
from .constraints import (
|
|
10
|
+
ConstraintScope,
|
|
11
|
+
apply_size_constraints,
|
|
12
|
+
invalidate_tree,
|
|
13
|
+
solve_constraints,
|
|
14
|
+
translate_tree,
|
|
15
|
+
walk,
|
|
16
|
+
)
|
|
17
|
+
from .node import BoxGeom, Col, Grid, Node, Row, Stack
|
|
18
|
+
from .primitives import Arrow, Box, Ellipsis, Group, LoopArrow, Matrix, Spacer, Text
|
|
19
|
+
from .render_svg import draw_rect, draw_text, svg_footer, svg_header
|
|
20
|
+
from .styles import (
|
|
21
|
+
Theme,
|
|
22
|
+
ThemeLike,
|
|
23
|
+
current_theme,
|
|
24
|
+
get_theme,
|
|
25
|
+
use_theme,
|
|
26
|
+
apply_edge_overrides,
|
|
27
|
+
apply_spacing_overrides,
|
|
28
|
+
normalize_justify,
|
|
29
|
+
normalize_pad,
|
|
30
|
+
)
|
|
31
|
+
from .textutil import measure_text
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class LayoutError(ValueError):
|
|
35
|
+
"""A physical layout cannot preserve the requested minimum font size."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Panel(ConstraintScope):
|
|
39
|
+
"""One lettered panel inside a Figure. Use as context manager or += content.
|
|
40
|
+
|
|
41
|
+
``panel.constrain(...)`` adds relational constraints (see codefig.constraints);
|
|
42
|
+
constraints registered on the Figure or on any Diagram inside this panel are
|
|
43
|
+
solved here too, after flex layout and before edge routing.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
letter: str,
|
|
49
|
+
title: str = "",
|
|
50
|
+
*,
|
|
51
|
+
theme: Optional[Theme] = None,
|
|
52
|
+
caption: Optional[str] = "bottom", # bottom | top | none
|
|
53
|
+
frame: Union[bool, str] = True,
|
|
54
|
+
fit: Optional[bool] = None,
|
|
55
|
+
fit_mode: Optional[str] = None, # scale|none (reflow → falls back to scale; documented)
|
|
56
|
+
max_scale: Optional[float] = None,
|
|
57
|
+
align: str = "center", # horizontal placement of fitted content
|
|
58
|
+
justify: Optional[str] = None, # vertical placement: start|center|end (None → auto)
|
|
59
|
+
pack: str = "center", # start|center|end — leftover space when not scaling
|
|
60
|
+
pad: Optional[object] = None,
|
|
61
|
+
padding: Optional[object] = None,
|
|
62
|
+
overlap: Optional[str] = None, # resolve|warn|error|ignore (None → "resolve")
|
|
63
|
+
align_edges: Optional[bool] = None, # v0.11.0 node-nudging edge alignment
|
|
64
|
+
subtitle: Optional[str] = None, # v0.12.0 second, smaller caption line
|
|
65
|
+
frame_color: Optional[str] = None, # v0.13.0
|
|
66
|
+
letters: bool = True, # v0.14.0: False drops the "(a)" tag, keeps the title
|
|
67
|
+
) -> None:
|
|
68
|
+
from .overlaps import check_policy
|
|
69
|
+
|
|
70
|
+
self.frame_color = frame_color
|
|
71
|
+
self.letters = bool(letters)
|
|
72
|
+
self.subtitle = subtitle
|
|
73
|
+
|
|
74
|
+
self.overlap = check_policy(overlap) or "resolve"
|
|
75
|
+
self.align_edges = True if align_edges is None else bool(align_edges)
|
|
76
|
+
self._auto_cons: list = []
|
|
77
|
+
self.letter = letter
|
|
78
|
+
self.title = title
|
|
79
|
+
self.theme = theme or current_theme()
|
|
80
|
+
self.content: Optional[Node] = None
|
|
81
|
+
self.geom = BoxGeom()
|
|
82
|
+
# caption: "bottom" (paper style, centered below content) or "top"
|
|
83
|
+
cap = "none" if caption in (None, False) else str(caption).lower()
|
|
84
|
+
if cap not in ("bottom", "top", "none"):
|
|
85
|
+
raise ValueError(f"caption must be 'bottom', 'top' or 'none', got {caption!r}")
|
|
86
|
+
self.caption = cap
|
|
87
|
+
# v0.13.0: frame="solid" | "dashed" | "dotted" (True = "solid")
|
|
88
|
+
if isinstance(frame, str):
|
|
89
|
+
fs_ = frame.lower()
|
|
90
|
+
if fs_ not in ("solid", "dashed", "dotted", "none"):
|
|
91
|
+
raise ValueError("panel frame must be True/False or 'solid'|'dashed'|'dotted'|'none'")
|
|
92
|
+
self.frame_style = None if fs_ == "none" else fs_
|
|
93
|
+
else:
|
|
94
|
+
self.frame_style = "solid" if frame else None
|
|
95
|
+
self.frame = self.frame_style is not None
|
|
96
|
+
# fit_mode: "scale" uniform zoom (default when fit=True); "none" content-size + pack;
|
|
97
|
+
# "reflow" is not implemented — falls back to scale (see README).
|
|
98
|
+
mode = fit_mode
|
|
99
|
+
if mode is None:
|
|
100
|
+
if fit is None: # physical themes keep content at print size (no zoom)
|
|
101
|
+
fit = not self.theme.physical
|
|
102
|
+
mode = "scale" if fit else "none"
|
|
103
|
+
mode = str(mode).lower()
|
|
104
|
+
if mode == "reflow":
|
|
105
|
+
mode = "scale" # honest fallback
|
|
106
|
+
self.fit_mode = mode
|
|
107
|
+
self.fit = mode == "scale" # BC
|
|
108
|
+
self.max_scale = float(self.theme.max_panel_scale if max_scale is None else max_scale)
|
|
109
|
+
self._force_scale: Optional[float] = None # Figure(panel_scale="uniform")
|
|
110
|
+
self.align = align # start|center|end
|
|
111
|
+
# v0.7.0: a top caption keeps its content right under it (leftover height goes
|
|
112
|
+
# below, where it separates the panel from the next row); otherwise centre.
|
|
113
|
+
if justify is None:
|
|
114
|
+
justify = "start" if (cap == "top" and self.theme.physical) else "center"
|
|
115
|
+
self.justify = normalize_justify(justify) # start|center|end
|
|
116
|
+
self.pack = pack if pack in ("start", "center", "end") else "center"
|
|
117
|
+
raw = padding if padding is not None else pad
|
|
118
|
+
self.padding = (
|
|
119
|
+
normalize_pad(raw) if raw is not None else normalize_pad(self.theme.panel_pad)
|
|
120
|
+
)
|
|
121
|
+
self._scale = 1.0
|
|
122
|
+
self._offset = (0.0, 0.0)
|
|
123
|
+
self._fig_constraints: list = [] # routed from Figure.constrain
|
|
124
|
+
self._overlays: list = []
|
|
125
|
+
self._bbox = (0.0, 0.0, 0.0, 0.0)
|
|
126
|
+
|
|
127
|
+
# ---- constraints ---------------------------------------------------------
|
|
128
|
+
def all_constraints(self) -> list:
|
|
129
|
+
"""Panel + Figure-routed + Diagram-scoped constraints for this panel."""
|
|
130
|
+
out = list(self._cf_constraints()) + list(self._fig_constraints)
|
|
131
|
+
if self.content is not None:
|
|
132
|
+
for n in walk(self.content):
|
|
133
|
+
lst = n.__dict__.get("_constraints") if hasattr(n, "__dict__") else None
|
|
134
|
+
if lst:
|
|
135
|
+
out.extend(lst)
|
|
136
|
+
return out
|
|
137
|
+
|
|
138
|
+
def _flex_and_solve(self, w: float, h: float, *, warn: bool = True, cons: Optional[list] = None):
|
|
139
|
+
"""Flex-layout content at the local origin, then solve constraints.
|
|
140
|
+
|
|
141
|
+
Returns the content bbox (x, y, w, h) in local (pre-scale) units.
|
|
142
|
+
"""
|
|
143
|
+
if cons is None:
|
|
144
|
+
cons = self.all_constraints() + list(self._auto_cons)
|
|
145
|
+
self.content.layout(0.0, 0.0, w, h)
|
|
146
|
+
res = solve_constraints(self.content, cons, warn=warn)
|
|
147
|
+
self._overlays = res.overlays
|
|
148
|
+
self._last_grow = res.growth
|
|
149
|
+
return res.bbox
|
|
150
|
+
|
|
151
|
+
def _solve_tolerant(self, w: float, h: float, user: list, auto: list, warn: bool):
|
|
152
|
+
"""Solve user + auto constraints; auto no_overlap constraints that clash with
|
|
153
|
+
required ones are downgraded to strong instead of raising."""
|
|
154
|
+
from .constraints import ConstraintConflictError
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
return self._flex_and_solve(w, h, warn=warn, cons=user + auto)
|
|
158
|
+
except ConstraintConflictError:
|
|
159
|
+
for c in auto:
|
|
160
|
+
if c.kind != "align_edge":
|
|
161
|
+
c.strength = "strong"
|
|
162
|
+
return self._flex_and_solve(w, h, warn=warn, cons=user + auto)
|
|
163
|
+
|
|
164
|
+
def _resolve_overlaps(self, w: float, h: float,
|
|
165
|
+
room: Optional[Tuple[float, float]] = None) -> list:
|
|
166
|
+
"""overlap='resolve': iteratively add no_overlap constraints (≤ 6 rounds)."""
|
|
167
|
+
from .overlaps import find_overlaps, resolution_for
|
|
168
|
+
|
|
169
|
+
self._auto_cons = []
|
|
170
|
+
if self.content is None:
|
|
171
|
+
return []
|
|
172
|
+
user = self.all_constraints()
|
|
173
|
+
auto: list = []
|
|
174
|
+
seen: set = set()
|
|
175
|
+
for _round in range(6 if self.overlap == "resolve" else 0):
|
|
176
|
+
if user or auto:
|
|
177
|
+
self._solve_tolerant(w, h, user, auto, warn=False)
|
|
178
|
+
else:
|
|
179
|
+
self.content.layout(0.0, 0.0, w, h)
|
|
180
|
+
self._overlays = []
|
|
181
|
+
found = find_overlaps(self.content, self._overlays, user)
|
|
182
|
+
new = []
|
|
183
|
+
for o in found:
|
|
184
|
+
key = frozenset((id(o.a), id(o.b)))
|
|
185
|
+
if key in seen:
|
|
186
|
+
continue
|
|
187
|
+
seen.add(key)
|
|
188
|
+
new.append(resolution_for(o, self._overlays))
|
|
189
|
+
if not new:
|
|
190
|
+
break
|
|
191
|
+
auto.extend(new)
|
|
192
|
+
auto = self._push_regions(w, h, user, auto)
|
|
193
|
+
auto = self._align_edges(w, h, user, auto, room)
|
|
194
|
+
self._auto_cons = auto
|
|
195
|
+
return auto
|
|
196
|
+
|
|
197
|
+
def _align_edges(self, w: float, h: float, user: list, auto: list,
|
|
198
|
+
room: Optional[Tuple[float, float]] = None) -> list:
|
|
199
|
+
"""v0.11.0: nudge connected nodes so facing ports line up (see codefig.edge_align).
|
|
200
|
+
|
|
201
|
+
``room`` is the panel's inner size during the final layout: containers may then
|
|
202
|
+
grow into space the panel has anyway (never during measuring, so a nudge never
|
|
203
|
+
makes a panel ask for more space)."""
|
|
204
|
+
from .constraints import invalidate_tree
|
|
205
|
+
from .edge_align import (breaks_flush, broken_sibling_pairs, candidates, flush_pairs,
|
|
206
|
+
flush_repairs, snapshot, visible_jogs)
|
|
207
|
+
from .overlaps import find_overlaps
|
|
208
|
+
|
|
209
|
+
if not self.align_edges or self.content is None:
|
|
210
|
+
return auto
|
|
211
|
+
key = (round(w, 1), round(h, 1), len(auto), len(user),
|
|
212
|
+
None if room is None else (round(room[0], 1), round(room[1], 1)))
|
|
213
|
+
cache = self.__dict__.setdefault("_align_cache", {})
|
|
214
|
+
if key in cache:
|
|
215
|
+
return auto + cache[key]
|
|
216
|
+
|
|
217
|
+
def solve(cons: list) -> Tuple[float, float, float, float]:
|
|
218
|
+
invalidate_tree(self.content)
|
|
219
|
+
self._last_grow = {}
|
|
220
|
+
if user or cons:
|
|
221
|
+
return self._solve_tolerant(w, h, user, cons, warn=False)
|
|
222
|
+
self.content.layout(0.0, 0.0, w, h)
|
|
223
|
+
self._overlays = []
|
|
224
|
+
g = self.content.geom
|
|
225
|
+
return (g.x, g.y, g.w, g.h)
|
|
226
|
+
|
|
227
|
+
bbox0 = solve(auto)
|
|
228
|
+
cands = candidates(self.content)
|
|
229
|
+
kept: list = []
|
|
230
|
+
if cands:
|
|
231
|
+
th = self.theme
|
|
232
|
+
max_shift = th.L(float(getattr(th, "align_max_shift", 24.0)))
|
|
233
|
+
if room is None: # measuring: a small, bounded growth budget
|
|
234
|
+
grow_max = th.L(float(getattr(th, "align_max_grow", 12.0)))
|
|
235
|
+
lim_w, lim_h = bbox0[2] + grow_max, bbox0[3] + grow_max
|
|
236
|
+
else: # final layout: only the space the panel already has
|
|
237
|
+
lim_w, lim_h = max(bbox0[2], room[0]), max(bbox0[3], room[1])
|
|
238
|
+
snap0 = snapshot(self.content)
|
|
239
|
+
flush0 = flush_pairs(self.content, snap0)
|
|
240
|
+
# v0.12.0 (A15): user constraints win — a nudge may not worsen any user
|
|
241
|
+
# relation, and nodes the user tied with an equality (same_x / same_y /
|
|
242
|
+
# align / place …) keep their position on that axis.
|
|
243
|
+
def _ures() -> List[float]:
|
|
244
|
+
out = []
|
|
245
|
+
for uc in user:
|
|
246
|
+
try:
|
|
247
|
+
out.append(max((r.residual() for r in uc.rels), default=0.0))
|
|
248
|
+
except Exception: # pragma: no cover - unresolvable custom anchor
|
|
249
|
+
out.append(0.0)
|
|
250
|
+
return out
|
|
251
|
+
|
|
252
|
+
ures0 = _ures()
|
|
253
|
+
pins: Dict[int, set] = {}
|
|
254
|
+
partners: Dict[Tuple[int, str], set] = {} # v0.13.0: nodes tied together
|
|
255
|
+
for uc in user:
|
|
256
|
+
for r in uc.rels:
|
|
257
|
+
if getattr(r, "op", "==") != "==":
|
|
258
|
+
continue
|
|
259
|
+
ids = [(id(a.node), a.axis) for a in r.anchors()
|
|
260
|
+
if getattr(a, "node", None) is not None]
|
|
261
|
+
for nid, ax in ids:
|
|
262
|
+
pins.setdefault(nid, set()).add(ax)
|
|
263
|
+
partners.setdefault((nid, ax), set()).update(
|
|
264
|
+
m for m, ax2 in ids if ax2 == ax and m != nid)
|
|
265
|
+
jogs = visible_jogs(self.content)
|
|
266
|
+
ov0 = len(find_overlaps(self.content, self._overlays, user))
|
|
267
|
+
|
|
268
|
+
dirty = False
|
|
269
|
+
dbg = _os.environ.get("CF_ALIGN_DEBUG")
|
|
270
|
+
for _prio, _e, c in cands:
|
|
271
|
+
if dirty:
|
|
272
|
+
solve(auto + kept)
|
|
273
|
+
dirty = False
|
|
274
|
+
if c.rels[0].residual() <= 0.05:
|
|
275
|
+
continue # already straight after earlier nudges
|
|
276
|
+
def check(extra: list) -> str:
|
|
277
|
+
bb = solve(auto + kept + extra)
|
|
278
|
+
if c.rels[0].residual() > 0.05:
|
|
279
|
+
return "unsat %.2f" % c.rels[0].residual()
|
|
280
|
+
if bb[2] > lim_w + 0.05 or bb[3] > lim_h + 0.05:
|
|
281
|
+
return "bbox"
|
|
282
|
+
if any(r1 > r0 + 0.05 for r0, r1 in zip(ures0, _ures())):
|
|
283
|
+
return "user"
|
|
284
|
+
grown = {k for k, g in (self._last_grow or {}).items()
|
|
285
|
+
if any(abs(v) > 0.05 for v in g)}
|
|
286
|
+
snap = snapshot(self.content)
|
|
287
|
+
# v0.13.0: a uniform shift of the whole content (re-centring in a
|
|
288
|
+
# roomier panel) moves nothing relative to anything else
|
|
289
|
+
tx, ty = bb[0] - bbox0[0], bb[1] - bbox0[1]
|
|
290
|
+
|
|
291
|
+
def _moved(nid: int, ax: str) -> float:
|
|
292
|
+
i = 0 if ax == "x" else 1
|
|
293
|
+
return snap[nid][i] - snap0.get(nid, snap[nid])[i] if nid in snap else 0.0
|
|
294
|
+
|
|
295
|
+
def _pin_broken(nid: int, ax: str) -> bool:
|
|
296
|
+
"""A tied node may move only together with everything it is tied to
|
|
297
|
+
(the user relation keeps its shape); alone it stays put."""
|
|
298
|
+
d = _moved(nid, ax)
|
|
299
|
+
t = tx if ax == "x" else ty
|
|
300
|
+
if min(abs(d), abs(d - t)) <= 0.05:
|
|
301
|
+
return False
|
|
302
|
+
ps = partners.get((nid, ax), set())
|
|
303
|
+
return not ps or any(abs(_moved(m, ax) - d) > 0.05 for m in ps)
|
|
304
|
+
|
|
305
|
+
for nid, (x, y, gw, gh) in snap.items():
|
|
306
|
+
x0, y0, w0, h0 = snap0.get(nid, (x, y, gw, gh))
|
|
307
|
+
if (abs(gw - w0) > 0.05 or abs(gh - h0) > 0.05) and nid not in grown:
|
|
308
|
+
return "size"
|
|
309
|
+
if abs(x - x0) > max_shift or abs(y - y0) > max_shift:
|
|
310
|
+
return "shift %.1f" % max(abs(x - x0), abs(y - y0))
|
|
311
|
+
pa = pins.get(nid)
|
|
312
|
+
if pa and any(_pin_broken(nid, ax) for ax in pa):
|
|
313
|
+
return "pinned"
|
|
314
|
+
if breaks_flush(flush0, snap, snap0):
|
|
315
|
+
return "flush"
|
|
316
|
+
return ""
|
|
317
|
+
|
|
318
|
+
extra = [c]
|
|
319
|
+
dirty = True
|
|
320
|
+
why = check(extra)
|
|
321
|
+
# v0.11.0: a nudge that only breaks sibling flush lines may take those
|
|
322
|
+
# siblings along (two rounds, so short chains follow too)
|
|
323
|
+
for _round in range(2):
|
|
324
|
+
if why != "flush":
|
|
325
|
+
break
|
|
326
|
+
broken = broken_sibling_pairs(flush0, snapshot(self.content))
|
|
327
|
+
if not broken:
|
|
328
|
+
break
|
|
329
|
+
extra = extra + flush_repairs(self.content, broken)
|
|
330
|
+
why = check(extra)
|
|
331
|
+
if not why:
|
|
332
|
+
j = visible_jogs(self.content)
|
|
333
|
+
if j >= jogs:
|
|
334
|
+
why = "jogs %d>=%d" % (j, jogs)
|
|
335
|
+
elif len(find_overlaps(self.content, self._overlays, user)) > ov0:
|
|
336
|
+
why = "overlap"
|
|
337
|
+
if dbg:
|
|
338
|
+
print("ALIGN", key[:2], c.desc, why or "OK")
|
|
339
|
+
if not why and dbg == "2":
|
|
340
|
+
from .constraints import node_name as _nn, walk as _walk
|
|
341
|
+
snap = snapshot(self.content)
|
|
342
|
+
for n in _walk(self.content):
|
|
343
|
+
x, y, gw, gh = snap[id(n)]
|
|
344
|
+
x0, y0, w0, h0 = snap0[id(n)]
|
|
345
|
+
if max(abs(x - x0), abs(y - y0), abs(gw - w0), abs(gh - h0)) > 0.05:
|
|
346
|
+
print(" moved", _nn(n), round(x - x0, 1), round(y - y0, 1),
|
|
347
|
+
round(gw - w0, 1), round(gh - h0, 1))
|
|
348
|
+
if not why:
|
|
349
|
+
kept.extend(extra)
|
|
350
|
+
jogs = j
|
|
351
|
+
dirty = False
|
|
352
|
+
solve(auto + kept)
|
|
353
|
+
cache[key] = kept
|
|
354
|
+
return auto + kept
|
|
355
|
+
|
|
356
|
+
def _push_regions(self, w: float, h: float, user: list, auto: list) -> list:
|
|
357
|
+
"""Move non-member boxes out of Diagram regions (``Region(exclude=)``).
|
|
358
|
+
|
|
359
|
+
v0.9.0: for every intruder two kinds of candidates are scored —
|
|
360
|
+
|
|
361
|
+
* *reorder*: move the intruder's flex unit (its ancestor that is a direct
|
|
362
|
+
child of the lowest container holding the members) just before / after the
|
|
363
|
+
members' span, so it leaves the region without changing the container shape;
|
|
364
|
+
* *push*: the four sides as strong no_overlap constraints against all members
|
|
365
|
+
(the v0.8.0 greedy push).
|
|
366
|
+
|
|
367
|
+
A candidate is valid when the intruder clears the region and the total
|
|
368
|
+
intrusion count drops; the cheapest valid one wins by
|
|
369
|
+
:meth:`_layout_cost` (intrusions ≫ edge-box hits ≫ crossings ≫ edge length ≫
|
|
370
|
+
footprint). Nothing valid → the carved outline handles it at draw time.
|
|
371
|
+
"""
|
|
372
|
+
from .edges import Diagram
|
|
373
|
+
from .regions import nest_pads, push_constraints, push_sides, region_intruders
|
|
374
|
+
|
|
375
|
+
regs = [(d, r) for d in walk(self.content) if isinstance(d, Diagram)
|
|
376
|
+
for r in d.regions if r.exclude in ("auto", "push")]
|
|
377
|
+
if not regs:
|
|
378
|
+
return auto
|
|
379
|
+
diagrams = []
|
|
380
|
+
for d, _r in regs:
|
|
381
|
+
if all(d is not x for x in diagrams):
|
|
382
|
+
diagrams.append(d)
|
|
383
|
+
|
|
384
|
+
def solve(cons: list) -> None:
|
|
385
|
+
invalidate_tree(self.content)
|
|
386
|
+
if user or cons:
|
|
387
|
+
self._solve_tolerant(w, h, user, cons, warn=False)
|
|
388
|
+
else:
|
|
389
|
+
self.content.layout(0.0, 0.0, w, h)
|
|
390
|
+
self._overlays = []
|
|
391
|
+
|
|
392
|
+
def intrusions() -> int:
|
|
393
|
+
for d, _r in regs:
|
|
394
|
+
nest_pads(d.regions)
|
|
395
|
+
return sum(len(region_intruders(d, r)) for d, r in regs)
|
|
396
|
+
|
|
397
|
+
solve(auto)
|
|
398
|
+
tried: set = set()
|
|
399
|
+
moves = self.__dict__.setdefault("_region_moves", [])
|
|
400
|
+
for _round in range(8):
|
|
401
|
+
progress = False
|
|
402
|
+
before = intrusions()
|
|
403
|
+
if not before:
|
|
404
|
+
break
|
|
405
|
+
for d, r in regs:
|
|
406
|
+
for intr in region_intruders(d, r):
|
|
407
|
+
key = (id(r), id(intr))
|
|
408
|
+
if key in tried:
|
|
409
|
+
continue
|
|
410
|
+
tried.add(key)
|
|
411
|
+
margin = r._pad() + r.theme.L(3.0)
|
|
412
|
+
cands: list = ([("order", mv) for mv in _reorder_moves(self.content, r, intr)]
|
|
413
|
+
if r.exclude == "auto" else [])
|
|
414
|
+
cands += [("push", push_constraints(r, intr, side, margin), side)
|
|
415
|
+
for _disp, side in push_sides(r, intr)]
|
|
416
|
+
best = None
|
|
417
|
+
for cand in cands:
|
|
418
|
+
if cand[0] == "order":
|
|
419
|
+
cand[1].apply()
|
|
420
|
+
solve(auto)
|
|
421
|
+
else:
|
|
422
|
+
solve(auto + cand[1])
|
|
423
|
+
g = intr.geom
|
|
424
|
+
n_in = intrusions()
|
|
425
|
+
if not r.intrudes((g.x, g.y, g.w, g.h)) and n_in < before:
|
|
426
|
+
cost = self._layout_cost(diagrams, n_in)
|
|
427
|
+
if best is None or cost < best[0]:
|
|
428
|
+
best = (cost, cand)
|
|
429
|
+
if cand[0] == "order":
|
|
430
|
+
cand[1].undo()
|
|
431
|
+
if best is not None:
|
|
432
|
+
cand = best[1]
|
|
433
|
+
if cand[0] == "order":
|
|
434
|
+
cand[1].apply()
|
|
435
|
+
rec = ("reorder", intr, cand[1].where)
|
|
436
|
+
else:
|
|
437
|
+
auto = auto + cand[1]
|
|
438
|
+
rec = ("push", intr, cand[2])
|
|
439
|
+
# layout runs several passes: record each move once
|
|
440
|
+
if not any(m[0] == rec[0] and m[1] is rec[1] and m[2] == rec[2]
|
|
441
|
+
for m in moves):
|
|
442
|
+
moves.append(rec)
|
|
443
|
+
progress = True
|
|
444
|
+
solve(auto)
|
|
445
|
+
if progress:
|
|
446
|
+
break
|
|
447
|
+
if progress:
|
|
448
|
+
break
|
|
449
|
+
if not progress:
|
|
450
|
+
break
|
|
451
|
+
return auto
|
|
452
|
+
|
|
453
|
+
def _layout_cost(self, diagrams: list, n_intrusions: int) -> Tuple[float, ...]:
|
|
454
|
+
"""Lexicographic cost of the current layout (see _push_regions)."""
|
|
455
|
+
from .regions import diagram_boxes
|
|
456
|
+
from .routing import count_crossings
|
|
457
|
+
|
|
458
|
+
length = 0.0
|
|
459
|
+
cross = 0
|
|
460
|
+
hits = 0
|
|
461
|
+
x0 = y0 = float("inf")
|
|
462
|
+
x1 = y1 = float("-inf")
|
|
463
|
+
for d in diagrams:
|
|
464
|
+
try:
|
|
465
|
+
routed = d._routed()[0]
|
|
466
|
+
except Exception: # pragma: no cover - routing failure: ignore edges
|
|
467
|
+
routed = []
|
|
468
|
+
paths = [pts for _e, pts in routed]
|
|
469
|
+
boxes, _parent = diagram_boxes(d)
|
|
470
|
+
leaves = [(n, rc) for n, rc in boxes if not list(n.children())]
|
|
471
|
+
for e, pts in routed:
|
|
472
|
+
for k in range(len(pts) - 1):
|
|
473
|
+
(ax, ay), (bx, by) = pts[k], pts[k + 1]
|
|
474
|
+
length += abs(bx - ax) + abs(by - ay)
|
|
475
|
+
for n, (rx, ry, rw, rh) in leaves:
|
|
476
|
+
if n is getattr(e.src, "node", e.src) or n is getattr(e.dst, "node", e.dst):
|
|
477
|
+
continue
|
|
478
|
+
if _seg_hits_rect(ax, ay, bx, by, rx, ry, rw, rh):
|
|
479
|
+
hits += 1
|
|
480
|
+
cross += count_crossings(paths)
|
|
481
|
+
for _n, (rx, ry, rw, rh) in boxes:
|
|
482
|
+
x0, y0 = min(x0, rx), min(y0, ry)
|
|
483
|
+
x1, y1 = max(x1, rx + rw), max(y1, ry + rh)
|
|
484
|
+
foot = (x1 - x0) + (y1 - y0) if x1 > x0 else 0.0
|
|
485
|
+
# edge length and footprint are compared in units of ~4 pt so tiny solver
|
|
486
|
+
# jitter does not decide between otherwise equal candidates
|
|
487
|
+
return (n_intrusions, hits, cross, round((length + foot) / 4.0))
|
|
488
|
+
|
|
489
|
+
def check_overlaps(self) -> list:
|
|
490
|
+
"""Overlaps among visible nodes in the laid-out panel (see codefig.overlaps)."""
|
|
491
|
+
from .overlaps import find_overlaps
|
|
492
|
+
|
|
493
|
+
if self.content is None:
|
|
494
|
+
return []
|
|
495
|
+
from .edges import Diagram
|
|
496
|
+
from .overlaps import find_edge_issues
|
|
497
|
+
|
|
498
|
+
cons = self.all_constraints()
|
|
499
|
+
out = find_overlaps(self.content, self._overlays, cons)
|
|
500
|
+
self._publish_overlay_obstacles()
|
|
501
|
+
for n in walk(self.content):
|
|
502
|
+
if isinstance(n, Diagram):
|
|
503
|
+
out.extend(find_edge_issues(n, self._overlays, cons))
|
|
504
|
+
for o in out:
|
|
505
|
+
o.panel = self.letter
|
|
506
|
+
return out
|
|
507
|
+
|
|
508
|
+
def _publish_overlay_obstacles(self) -> None:
|
|
509
|
+
"""Hand place() annotations to every Diagram so its edges route around them."""
|
|
510
|
+
from .edges import Diagram
|
|
511
|
+
|
|
512
|
+
obs = [o for o in self._overlays if getattr(o, "_cf_obstacle", True)]
|
|
513
|
+
for n in walk(self.content):
|
|
514
|
+
if isinstance(n, Diagram):
|
|
515
|
+
n._cf_overlay_obstacles = obs
|
|
516
|
+
n._cf_overlays_all = list(self._overlays)
|
|
517
|
+
n._placed_labels = None
|
|
518
|
+
|
|
519
|
+
def _after_layout(self) -> None:
|
|
520
|
+
from .overlaps import emit
|
|
521
|
+
|
|
522
|
+
self._publish_overlay_obstacles()
|
|
523
|
+
if self.overlap == "ignore":
|
|
524
|
+
return
|
|
525
|
+
found = self.check_overlaps()
|
|
526
|
+
emit(found, "warn" if self.overlap == "resolve" else self.overlap)
|
|
527
|
+
|
|
528
|
+
def _prepare_sizes(self) -> None:
|
|
529
|
+
if self.content is not None:
|
|
530
|
+
from .legend import Legend
|
|
531
|
+
|
|
532
|
+
corners = self.__dict__.setdefault("_corner_legends", [])
|
|
533
|
+
for parent in list(walk(self.content)):
|
|
534
|
+
if hasattr(parent, "_kids"):
|
|
535
|
+
for child in list(parent._kids):
|
|
536
|
+
if isinstance(child, Legend) and child.corner is not None and child.overlay:
|
|
537
|
+
parent._kids.remove(child)
|
|
538
|
+
corners.append(child)
|
|
539
|
+
parent.invalidate()
|
|
540
|
+
for legend in corners:
|
|
541
|
+
legend.collect(self.content)
|
|
542
|
+
cons = self.all_constraints()
|
|
543
|
+
trees = [self.content] + [c.overlay for c in cons if getattr(c, "overlay", None) is not None]
|
|
544
|
+
for t in trees:
|
|
545
|
+
for n in walk(t):
|
|
546
|
+
if isinstance(n, Legend):
|
|
547
|
+
if isinstance(n.source, str) and n.source == "figure":
|
|
548
|
+
fig = self.__dict__.get("_fig")
|
|
549
|
+
roots = ([q.content for q in (fig._panels[k] for k in fig._order)
|
|
550
|
+
if q.content is not None] if fig is not None
|
|
551
|
+
else [self.content])
|
|
552
|
+
n.collect(roots)
|
|
553
|
+
else:
|
|
554
|
+
n.collect(self.content)
|
|
555
|
+
apply_size_constraints(self.content, cons)
|
|
556
|
+
|
|
557
|
+
def __enter__(self) -> "Panel":
|
|
558
|
+
return self
|
|
559
|
+
|
|
560
|
+
def __exit__(self, *args: object) -> None:
|
|
561
|
+
return None
|
|
562
|
+
|
|
563
|
+
def __iadd__(self, node: Node) -> "Panel":
|
|
564
|
+
if self.content is None:
|
|
565
|
+
self.content = node
|
|
566
|
+
elif isinstance(self.content, Stack):
|
|
567
|
+
self.content.add(node)
|
|
568
|
+
else:
|
|
569
|
+
self.content = Stack([self.content, node], gap=self.theme.gap, align="center")
|
|
570
|
+
return self
|
|
571
|
+
|
|
572
|
+
def set(self, node: Node) -> "Panel":
|
|
573
|
+
self.content = node
|
|
574
|
+
return self
|
|
575
|
+
|
|
576
|
+
def _sub_fs(self) -> float:
|
|
577
|
+
return self.theme.panel_title_size * 0.85
|
|
578
|
+
|
|
579
|
+
def _sub_lines(self) -> List[str]:
|
|
580
|
+
"""Subtitle lines: explicit ``\n`` breaks, plus (v0.13.0) automatic wrapping
|
|
581
|
+
to the panel width computed in :meth:`measure_content`."""
|
|
582
|
+
if not self.subtitle:
|
|
583
|
+
return []
|
|
584
|
+
cached = self.__dict__.get("_sub_cache")
|
|
585
|
+
if cached is not None and cached[0] == self.subtitle:
|
|
586
|
+
return list(cached[1])
|
|
587
|
+
return str(self.subtitle).split("\n")
|
|
588
|
+
|
|
589
|
+
def _wrap_sub(self, width: float) -> List[str]:
|
|
590
|
+
fs = self._sub_fs()
|
|
591
|
+
out: List[str] = []
|
|
592
|
+
for para in str(self.subtitle).split("\n"):
|
|
593
|
+
if measure_text(para, fs)[0] <= width + 0.5 or " " not in para:
|
|
594
|
+
out.append(para)
|
|
595
|
+
continue
|
|
596
|
+
cur = ""
|
|
597
|
+
for word in para.split(" "):
|
|
598
|
+
cand = (cur + " " + word) if cur else word
|
|
599
|
+
if cur and measure_text(cand, fs)[0] > width + 0.5:
|
|
600
|
+
out.append(cur)
|
|
601
|
+
cur = word
|
|
602
|
+
else:
|
|
603
|
+
cur = cand
|
|
604
|
+
if cur:
|
|
605
|
+
out.append(cur)
|
|
606
|
+
self.__dict__["_sub_cache"] = (self.subtitle, list(out))
|
|
607
|
+
return out
|
|
608
|
+
|
|
609
|
+
def _title_lines(self) -> List[str]:
|
|
610
|
+
"""v0.13.0: a ``\n`` in a panel title gives a multi-line caption."""
|
|
611
|
+
return self.caption_text().split("\n")
|
|
612
|
+
|
|
613
|
+
def _sub_h(self) -> float:
|
|
614
|
+
"""Height of the optional subtitle line(s) under the caption (v0.12.0)."""
|
|
615
|
+
if not self.subtitle or self.caption == "none":
|
|
616
|
+
return 0.0
|
|
617
|
+
return self._sub_fs() * 1.25 * len(self._sub_lines())
|
|
618
|
+
|
|
619
|
+
def _caption_h(self, k: float) -> float:
|
|
620
|
+
if self._no_caption():
|
|
621
|
+
return 0.0
|
|
622
|
+
th = self.theme
|
|
623
|
+
if th.physical: # one exact value for measure and layout
|
|
624
|
+
n = len(self._title_lines())
|
|
625
|
+
return th.panel_title_size * 1.2 * n + th.title_gap + self._sub_h()
|
|
626
|
+
return th.panel_title_size * k + self._sub_h()
|
|
627
|
+
|
|
628
|
+
def measure_content(self) -> Tuple[float, float]:
|
|
629
|
+
th = self.theme
|
|
630
|
+
if self.content is None:
|
|
631
|
+
return 120.0, 80.0 + self._caption_h(1.6)
|
|
632
|
+
self._prepare_sizes()
|
|
633
|
+
cw, ch = self.content.measure()
|
|
634
|
+
auto = self._resolve_overlaps(cw, ch)
|
|
635
|
+
user = self.all_constraints()
|
|
636
|
+
if user or auto:
|
|
637
|
+
_bx, _by, cw, ch = self._solve_tolerant(cw, ch, user, auto, warn=False)
|
|
638
|
+
# letter + title row needs space on the left/top; caption must fit too
|
|
639
|
+
label = self.caption_text()
|
|
640
|
+
if th.physical:
|
|
641
|
+
pt, pr, pb, pl = self.padding
|
|
642
|
+
lw = 0.0 if self._no_caption() else max(
|
|
643
|
+
measure_text(t, th.panel_title_size, weight="bold")[0] for t in self._title_lines())
|
|
644
|
+
if self.subtitle and self.caption != "none":
|
|
645
|
+
# v0.13.0: the subtitle wraps to the panel width instead of widening it
|
|
646
|
+
sfs = self._sub_fs()
|
|
647
|
+
widest = max(measure_text(t, sfs)[0] for t in str(self.subtitle).split("\n"))
|
|
648
|
+
wrap_w = max(cw, lw, min(widest, sfs * 14))
|
|
649
|
+
lw = max([lw] + [measure_text(t, sfs)[0] for t in self._wrap_sub(wrap_w)])
|
|
650
|
+
title_h = self._caption_h(1.6)
|
|
651
|
+
# v0.13.0: width parts for the CF-EXP-001 width budget
|
|
652
|
+
self._wparts = {"content": cw, "caption": lw, "pad": pl + pr}
|
|
653
|
+
return max(cw, lw) + pl + pr, ch + title_h + pt + pb
|
|
654
|
+
title_h = self._caption_h(1.6)
|
|
655
|
+
lw = 0.0 if self._no_caption() else measure_text(label, th.panel_title_size * 1.15)[0]
|
|
656
|
+
return max(cw, lw) + 2 * th.panel_pad, ch + title_h + 2 * th.panel_pad
|
|
657
|
+
|
|
658
|
+
def caption_text(self) -> str:
|
|
659
|
+
"""``"(a) title"`` — the panel tag plus title as drawn (just the title with
|
|
660
|
+
``letters=False`` / ``Figure(panel_letters=False)``)."""
|
|
661
|
+
if not self.letters:
|
|
662
|
+
return str(self.title or "")
|
|
663
|
+
return f"({self.letter}) {self.title}" if self.title else f"({self.letter})"
|
|
664
|
+
|
|
665
|
+
def _no_caption(self) -> bool:
|
|
666
|
+
return self.caption == "none" or (not self.caption_text() and not self.subtitle)
|
|
667
|
+
|
|
668
|
+
def _inner(self) -> Tuple[float, float, float, float]:
|
|
669
|
+
th = self.theme
|
|
670
|
+
g = self.geom
|
|
671
|
+
title_h = self._caption_h(1.8)
|
|
672
|
+
pt, pr, pb, pl = self.padding
|
|
673
|
+
if th.physical:
|
|
674
|
+
top = title_h if self.caption == "top" else 0.0
|
|
675
|
+
return g.x + pl, g.y + pt + top, g.w - pl - pr, g.h - title_h - pt - pb
|
|
676
|
+
if self.caption == "top":
|
|
677
|
+
return (
|
|
678
|
+
g.x + pl,
|
|
679
|
+
g.y + title_h + pt * 0.5,
|
|
680
|
+
g.w - pl - pr,
|
|
681
|
+
g.h - title_h - pt * 0.5 - pb,
|
|
682
|
+
)
|
|
683
|
+
return g.x + pl, g.y + pt, g.w - pl - pr, g.h - title_h - pt - pb
|
|
684
|
+
|
|
685
|
+
def _pack_offset(
|
|
686
|
+
self, ix: float, iy: float, iw: float, ih: float, sw: float, sh: float
|
|
687
|
+
) -> Tuple[float, float]:
|
|
688
|
+
"""Place content box in leftover panel space (pack / align / justify)."""
|
|
689
|
+
# Horizontal: prefer align; pack only applies when align is center-ish default
|
|
690
|
+
if self.align == "start":
|
|
691
|
+
ox = ix
|
|
692
|
+
elif self.align == "end":
|
|
693
|
+
ox = ix + (iw - sw)
|
|
694
|
+
else:
|
|
695
|
+
ox = ix + (iw - sw) / 2
|
|
696
|
+
# Vertical: pack is the leftover-space knob (distinct from fit-scale)
|
|
697
|
+
pack = self.pack
|
|
698
|
+
if self.justify == "start" or pack == "start":
|
|
699
|
+
# justify wins when explicitly start/end; pack used when justify=center
|
|
700
|
+
if self.justify == "start":
|
|
701
|
+
oy = iy
|
|
702
|
+
elif self.justify == "end":
|
|
703
|
+
oy = iy + (ih - sh)
|
|
704
|
+
elif pack == "start":
|
|
705
|
+
oy = iy
|
|
706
|
+
elif pack == "end":
|
|
707
|
+
oy = iy + (ih - sh)
|
|
708
|
+
else:
|
|
709
|
+
oy = iy + (ih - sh) / 2
|
|
710
|
+
elif self.justify == "end":
|
|
711
|
+
oy = iy + (ih - sh)
|
|
712
|
+
else:
|
|
713
|
+
if pack == "start":
|
|
714
|
+
oy = iy
|
|
715
|
+
elif pack == "end":
|
|
716
|
+
oy = iy + (ih - sh)
|
|
717
|
+
else:
|
|
718
|
+
oy = iy + (ih - sh) / 2
|
|
719
|
+
return ox, oy
|
|
720
|
+
|
|
721
|
+
def layout(self, x: float, y: float, w: float, h: float) -> None:
|
|
722
|
+
self.geom = BoxGeom(x, y, w, h)
|
|
723
|
+
if self.content is None:
|
|
724
|
+
return
|
|
725
|
+
ix, iy, iw, ih = self._inner()
|
|
726
|
+
self._prepare_sizes()
|
|
727
|
+
cw, ch = self.content.measure()
|
|
728
|
+
cw, ch = max(cw, 1.0), max(ch, 1.0)
|
|
729
|
+
self._overlays = []
|
|
730
|
+
self._bbox = (0.0, 0.0, cw, ch)
|
|
731
|
+
lw = cw if self.fit_mode == "scale" else max(cw, iw)
|
|
732
|
+
auto = self._resolve_overlaps(lw, ch, None if self.fit_mode == "scale" else (iw, ih))
|
|
733
|
+
user = self.all_constraints()
|
|
734
|
+
self._overlays = []
|
|
735
|
+
if user or auto:
|
|
736
|
+
# flex → constraint solve → fit the *solved* bbox
|
|
737
|
+
bx, by, bw, bh = self._solve_tolerant(lw, ch, user, auto, warn=True)
|
|
738
|
+
bw, bh = max(bw, 1.0), max(bh, 1.0)
|
|
739
|
+
self._bbox = (bx, by, bw, bh)
|
|
740
|
+
if self.fit_mode == "scale":
|
|
741
|
+
s = min(iw / bw, ih / bh, self.max_scale) * 0.95
|
|
742
|
+
if self._force_scale is not None:
|
|
743
|
+
s = min(s, self._force_scale)
|
|
744
|
+
self._scale = s
|
|
745
|
+
self._offset = self._pack_offset(ix, iy, iw, ih, bw * s, bh * s)
|
|
746
|
+
else:
|
|
747
|
+
self._scale = 1.0
|
|
748
|
+
ox, oy = self._pack_offset(ix, iy, iw, ih, bw, bh)
|
|
749
|
+
translate_tree(self.content, ox - bx, oy - by)
|
|
750
|
+
for o in self._overlays:
|
|
751
|
+
translate_tree(o, ox - bx, oy - by)
|
|
752
|
+
self._bbox = (ox, oy, bw, bh)
|
|
753
|
+
self._offset = (0.0, 0.0)
|
|
754
|
+
self._after_layout()
|
|
755
|
+
return
|
|
756
|
+
if self.fit_mode == "scale":
|
|
757
|
+
s = min(iw / cw, ih / ch, self.max_scale) * 0.95
|
|
758
|
+
if self._force_scale is not None:
|
|
759
|
+
s = min(s, self._force_scale)
|
|
760
|
+
self.content.layout(0.0, 0.0, cw, ch)
|
|
761
|
+
self._scale = s
|
|
762
|
+
sw, sh = cw * s, ch * s
|
|
763
|
+
self._offset = self._pack_offset(ix, iy, iw, ih, sw, sh)
|
|
764
|
+
else:
|
|
765
|
+
# fit_mode == "none": content-sized, pack leftover (no uniform scale)
|
|
766
|
+
self._scale = 1.0
|
|
767
|
+
# Give content at least its measure; allow fill children to use panel width
|
|
768
|
+
lw = cw if self.theme.physical else max(cw, iw)
|
|
769
|
+
self.content.layout(0.0, 0.0, lw, ch)
|
|
770
|
+
# Re-measure after layout? keep cw,ch natural for packing
|
|
771
|
+
sw, sh = cw, ch
|
|
772
|
+
ox, oy = self._pack_offset(ix, iy, iw, ih, sw, sh)
|
|
773
|
+
# Re-layout at absolute origin so draw needs no scale transform
|
|
774
|
+
self.content.layout(ox, oy, lw, ch)
|
|
775
|
+
self._offset = (0.0, 0.0)
|
|
776
|
+
self._after_layout()
|
|
777
|
+
|
|
778
|
+
def draw(self, buf: List[str]) -> None:
|
|
779
|
+
th = self.theme
|
|
780
|
+
g = self.geom
|
|
781
|
+
# Panel frame
|
|
782
|
+
if self.frame:
|
|
783
|
+
draw_rect(
|
|
784
|
+
buf,
|
|
785
|
+
g.x,
|
|
786
|
+
g.y,
|
|
787
|
+
g.w,
|
|
788
|
+
g.h,
|
|
789
|
+
fill="#ffffff",
|
|
790
|
+
stroke=self.frame_color or th.stroke,
|
|
791
|
+
stroke_width=th.frame_stroke,
|
|
792
|
+
radius=0,
|
|
793
|
+
dasharray={"dashed": "3,2", "dotted": "1,1.5"}.get(self.frame_style or ""),
|
|
794
|
+
)
|
|
795
|
+
# Caption: (a) title
|
|
796
|
+
label = self.caption_text()
|
|
797
|
+
pt, pr, pb, pl = self.padding
|
|
798
|
+
if self._no_caption():
|
|
799
|
+
pass
|
|
800
|
+
elif th.physical:
|
|
801
|
+
fs = th.panel_title_size
|
|
802
|
+
sh = self._sub_h()
|
|
803
|
+
tls = self._title_lines()
|
|
804
|
+
if self.caption == "top":
|
|
805
|
+
ty = g.y + pt + fs * 0.9
|
|
806
|
+
else:
|
|
807
|
+
ty = g.y + g.h - pb - fs * 0.25 - sh - fs * 1.2 * (len(tls) - 1)
|
|
808
|
+
for i, t in enumerate(tls):
|
|
809
|
+
draw_text(buf, t, g.x + pl, ty + fs * 1.2 * i, font_size=fs, weight="bold")
|
|
810
|
+
ty += fs * 1.2 * (len(tls) - 1)
|
|
811
|
+
if sh:
|
|
812
|
+
sfs = self._sub_fs()
|
|
813
|
+
for i, t in enumerate(self._sub_lines()):
|
|
814
|
+
draw_text(buf, t, g.x + pl, ty + fs * 0.3 + sfs * (1.0 + 1.25 * i),
|
|
815
|
+
font_size=sfs, fill=th.muted_color)
|
|
816
|
+
elif self.caption == "top":
|
|
817
|
+
draw_text(
|
|
818
|
+
buf,
|
|
819
|
+
label,
|
|
820
|
+
g.x + th.panel_pad,
|
|
821
|
+
g.y + th.panel_pad + th.panel_title_size,
|
|
822
|
+
font_size=th.panel_title_size,
|
|
823
|
+
weight="bold",
|
|
824
|
+
)
|
|
825
|
+
else:
|
|
826
|
+
fs = th.panel_title_size * 1.15
|
|
827
|
+
lw, _ = measure_text(label, fs)
|
|
828
|
+
draw_text(
|
|
829
|
+
buf,
|
|
830
|
+
label,
|
|
831
|
+
g.x + (g.w - lw) / 2,
|
|
832
|
+
g.y + g.h - th.panel_pad * 0.8,
|
|
833
|
+
font_size=fs,
|
|
834
|
+
)
|
|
835
|
+
if self.content is not None:
|
|
836
|
+
if self.fit_mode == "scale":
|
|
837
|
+
ox, oy = self._offset
|
|
838
|
+
bx, by = self._bbox[0], self._bbox[1]
|
|
839
|
+
tr = f"translate({ox:.2f},{oy:.2f}) scale({self._scale:.4f})"
|
|
840
|
+
if abs(bx) > 1e-6 or abs(by) > 1e-6:
|
|
841
|
+
tr += f" translate({-bx:.2f},{-by:.2f})"
|
|
842
|
+
buf.append(f'<g transform="{tr}">')
|
|
843
|
+
self.content.draw(buf)
|
|
844
|
+
for o in self._overlays + self.__dict__.get("_corner_legends", []):
|
|
845
|
+
o.draw(buf)
|
|
846
|
+
buf.append("</g>")
|
|
847
|
+
else:
|
|
848
|
+
self.content.draw(buf)
|
|
849
|
+
for o in self._overlays + self.__dict__.get("_corner_legends", []):
|
|
850
|
+
o.draw(buf)
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def _grid_rects(rows: List[List[str]]) -> Dict[str, Tuple[int, int, int, int]]:
|
|
854
|
+
"""letter → (row, col, rowspan, colspan) from a ``Figure(grid=...)`` spec."""
|
|
855
|
+
cells: Dict[str, List[Tuple[int, int]]] = {}
|
|
856
|
+
for r, row in enumerate(rows):
|
|
857
|
+
for c, k in enumerate(row):
|
|
858
|
+
if k not in (".", " ", ""):
|
|
859
|
+
cells.setdefault(k, []).append((r, c))
|
|
860
|
+
out: Dict[str, Tuple[int, int, int, int]] = {}
|
|
861
|
+
for k, cs in cells.items():
|
|
862
|
+
r0, r1 = min(r for r, _ in cs), max(r for r, _ in cs)
|
|
863
|
+
c0, c1 = min(c for _, c in cs), max(c for _, c in cs)
|
|
864
|
+
if len(cs) != (r1 - r0 + 1) * (c1 - c0 + 1):
|
|
865
|
+
raise ValueError(f"Figure grid: panel {k!r} does not form a rectangle")
|
|
866
|
+
out[k] = (r0, c0, r1 - r0 + 1, c1 - c0 + 1)
|
|
867
|
+
return out
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
class Figure(ConstraintScope):
|
|
871
|
+
"""
|
|
872
|
+
Top-level figure canvas.
|
|
873
|
+
|
|
874
|
+
Physical height fitting parameters:
|
|
875
|
+
|
|
876
|
+
| Parameter | Default | Meaning |
|
|
877
|
+
| --- | --- | --- |
|
|
878
|
+
| aspect | None | Target width/height; mutually exclusive with height. |
|
|
879
|
+
| height | None | Target height in pt. |
|
|
880
|
+
| max_height | None | Height cap; never adds padding. |
|
|
881
|
+
| fit | "compact" | none, compact, scale or pad; active only with a target. |
|
|
882
|
+
| min_font | 5.0 | Minimum printed font size in pt for scale. |
|
|
883
|
+
|
|
884
|
+
Targets must be positive and finite. Compact compresses automatic spacing
|
|
885
|
+
only (down to 0.6); explicit gaps and padding stay unchanged. Scale then
|
|
886
|
+
shrinks uniformly or raises LayoutError at the font floor. Pad centers
|
|
887
|
+
shorter content vertically. layout_report() exposes fitting diagnostics.
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
Panels are arranged in a grid (default 2 columns, filled row-major). Layout is
|
|
891
|
+
constraint/flex based — the public API does not take absolute author coordinates.
|
|
892
|
+
|
|
893
|
+
v0.5.0 spans: ``fig.panel("c", colspan=2)`` makes a panel span several grid
|
|
894
|
+
columns (``rowspan=`` rows); auto placement takes the first free slot that fits,
|
|
895
|
+
row-major. Or give the whole arrangement as ``grid=["aab", "ccb"]`` (one string or
|
|
896
|
+
list per row, one panel letter per cell, ``"."`` for an empty cell; each letter
|
|
897
|
+
must form a rectangle) — ``cols`` then follows the grid.
|
|
898
|
+
|
|
899
|
+
Args (layout-related):
|
|
900
|
+
title: Figure title.
|
|
901
|
+
size: ``(w, h)`` in px or ``"auto"`` (canvas cropped to the content).
|
|
902
|
+
cols: Grid columns for auto placement (default 2; ignored with ``grid``).
|
|
903
|
+
grid: Explicit panel arrangement (see above). Default ``None``.
|
|
904
|
+
equal_heights: ``"row"`` (default — panels in one grid row share its height),
|
|
905
|
+
``"all"`` (every row as tall as the tallest) or ``False`` (each panel
|
|
906
|
+
keeps its natural height, top-aligned in its row).
|
|
907
|
+
widths / heights (v0.12.0): column / row size ratios. ``widths`` is a
|
|
908
|
+
spare-room hint, not a strict ratio under a fixed venue width. For example,
|
|
909
|
+
``Figure(grid=["ab"], widths=[5, 4])``: the venue width (minus panel gaps
|
|
910
|
+
and margins) is shared 5 : 4. Leave it out unless you want *unequal
|
|
911
|
+
spare room*: by default every column already gets its content's natural
|
|
912
|
+
width plus an equal part of the spare room. v0.13.0: a share narrower
|
|
913
|
+
than a column's content no longer inflates the figure — that column keeps
|
|
914
|
+
its natural width and the others split the rest; only when the natural
|
|
915
|
+
widths alone exceed the slot does everything shrink (CF-EXP-001 lists the
|
|
916
|
+
width budget). Without a fixed width (``size="auto"``) the shares are
|
|
917
|
+
exact and the figure grows until every column fits.
|
|
918
|
+
panel_dividers (v0.12.0): ``"solid"`` (or ``True``) / ``"dashed"`` /
|
|
919
|
+
``"dotted"`` rule lines in the gaps between neighbouring panels;
|
|
920
|
+
``None``/``False`` (default) draws none.
|
|
921
|
+
|
|
922
|
+
``fig.panel("a", "title", subtitle="one-line detail")`` adds a smaller second caption
|
|
923
|
+
line (v0.12.0).
|
|
924
|
+
|
|
925
|
+
Example:
|
|
926
|
+
>>> from codefig import Figure, Box
|
|
927
|
+
>>> fig = Figure(size="auto", grid=["ab", "cc"])
|
|
928
|
+
>>> for k in "abc":
|
|
929
|
+
... _ = fig.panel(k, f"panel {k}").set(Box(k))
|
|
930
|
+
>>> svg = fig.to_svg()
|
|
931
|
+
"""
|
|
932
|
+
|
|
933
|
+
def __init__(
|
|
934
|
+
self,
|
|
935
|
+
title: str = "",
|
|
936
|
+
size: Union[Tuple[float, float], str, None] = None,
|
|
937
|
+
*,
|
|
938
|
+
venue: Optional[str] = None,
|
|
939
|
+
width: Union[str, float, None] = None,
|
|
940
|
+
cols: int = 2,
|
|
941
|
+
theme: ThemeLike = None,
|
|
942
|
+
edge: Optional[dict] = None,
|
|
943
|
+
spacing: Optional[dict] = None,
|
|
944
|
+
title_pos: str = "bottom",
|
|
945
|
+
panel_caption: Optional[str] = "inherit",
|
|
946
|
+
panel_frame: Optional[bool] = None,
|
|
947
|
+
panel_scale: str = "fit",
|
|
948
|
+
panel_align: str = "center",
|
|
949
|
+
panel_justify: Optional[str] = None,
|
|
950
|
+
canvas_pad: Optional[float] = None,
|
|
951
|
+
overlap: str = "resolve",
|
|
952
|
+
roles: Optional[Dict[str, Any]] = None,
|
|
953
|
+
knockout: Optional[bool] = None,
|
|
954
|
+
grid: Optional[Sequence[Union[str, Sequence[str]]]] = None,
|
|
955
|
+
equal_heights: Union[str, bool] = "row",
|
|
956
|
+
align_edges: bool = True,
|
|
957
|
+
widths: Optional[Sequence[float]] = None,
|
|
958
|
+
heights: Optional[Sequence[float]] = None,
|
|
959
|
+
panel_dividers: Union[str, bool, None] = None,
|
|
960
|
+
panel_letters: bool = True,
|
|
961
|
+
auto_wrap: bool = True,
|
|
962
|
+
font_size: Optional[float] = None,
|
|
963
|
+
categories: Optional[Sequence[str]] = None,
|
|
964
|
+
aspect: Optional[float] = None,
|
|
965
|
+
height: Optional[float] = None,
|
|
966
|
+
max_height: Optional[float] = None,
|
|
967
|
+
fit: str = "compact",
|
|
968
|
+
min_font: float = 5.0,
|
|
969
|
+
) -> None:
|
|
970
|
+
if aspect is not None and height is not None:
|
|
971
|
+
raise ValueError("aspect= and height= are mutually exclusive; pass one")
|
|
972
|
+
import math
|
|
973
|
+
for name, value in (("aspect", aspect), ("height", height),
|
|
974
|
+
("max_height", max_height), ("min_font", min_font)):
|
|
975
|
+
if value is not None and (not math.isfinite(value) or value <= 0):
|
|
976
|
+
raise ValueError(f"{name} must be positive and finite; received {value!r}")
|
|
977
|
+
if fit not in ("none", "compact", "scale", "pad"):
|
|
978
|
+
raise ValueError("fit must be none | compact | scale | pad")
|
|
979
|
+
self.target_aspect = aspect
|
|
980
|
+
self.target_height = height
|
|
981
|
+
self.max_height = max_height
|
|
982
|
+
self.fit = fit
|
|
983
|
+
self.min_font = min_font
|
|
984
|
+
from .overlaps import check_policy
|
|
985
|
+
from .policy import normalize_role
|
|
986
|
+
|
|
987
|
+
self.panel_letters = bool(panel_letters)
|
|
988
|
+
self.auto_wrap = bool(auto_wrap)
|
|
989
|
+
# v0.14.0: declared category order (palette index) — fig.color(name) appends
|
|
990
|
+
self._cat_order: List[str] = []
|
|
991
|
+
for c in categories or ():
|
|
992
|
+
if str(c) not in self._cat_order:
|
|
993
|
+
self._cat_order.append(str(c))
|
|
994
|
+
|
|
995
|
+
if panel_dividers is True: # v0.13.0: bool alias (True = "solid")
|
|
996
|
+
panel_dividers = "solid"
|
|
997
|
+
elif panel_dividers is False:
|
|
998
|
+
panel_dividers = None
|
|
999
|
+
if panel_dividers not in (None, "none", "solid", "dashed", "dotted"):
|
|
1000
|
+
raise ValueError("panel_dividers must be None/False, True/'solid', 'dashed' or 'dotted'")
|
|
1001
|
+
self.panel_dividers = None if panel_dividers == "none" else panel_dividers
|
|
1002
|
+
self.widths = [float(v) for v in widths] if widths is not None else None
|
|
1003
|
+
self.heights = [float(v) for v in heights] if heights is not None else None
|
|
1004
|
+
for nm, v in (("widths", self.widths), ("heights", self.heights)):
|
|
1005
|
+
if v is not None and (not v or any(x <= 0 for x in v)):
|
|
1006
|
+
raise ValueError(f"{nm} must be positive ratios, e.g. {nm}=[2, 1]")
|
|
1007
|
+
self.overlap = check_policy(overlap) or "resolve"
|
|
1008
|
+
self.align_edges = bool(align_edges)
|
|
1009
|
+
# figure-level layering defaults: {"Text": "hard", "Group": "none", …}
|
|
1010
|
+
self.roles = {str(k): normalize_role(v) for k, v in (roles or {}).items()}
|
|
1011
|
+
self.knockout = knockout
|
|
1012
|
+
from .venues import get_venue, resolve_width_pt
|
|
1013
|
+
|
|
1014
|
+
if theme is None:
|
|
1015
|
+
th = current_theme()
|
|
1016
|
+
if venue is not None and not th.physical:
|
|
1017
|
+
th = get_theme("paper") # a venue implies print units
|
|
1018
|
+
else:
|
|
1019
|
+
th = get_theme(theme)
|
|
1020
|
+
th = apply_spacing_overrides(th, spacing)
|
|
1021
|
+
th = apply_edge_overrides(th, edge)
|
|
1022
|
+
if font_size is not None: # v0.14.0 (C10): figure-wide base text size
|
|
1023
|
+
from dataclasses import replace as _replace
|
|
1024
|
+
|
|
1025
|
+
fsz = float(font_size)
|
|
1026
|
+
if th.font_sizes and fsz not in th.font_sizes:
|
|
1027
|
+
raise ValueError(f"font_size={font_size!r}: the {th.name} theme uses the size roles "
|
|
1028
|
+
f"{tuple(th.font_sizes)} pt (lint CF-TXT-002); pick one of them")
|
|
1029
|
+
th = _replace(th, font_size=fsz)
|
|
1030
|
+
self.theme = th
|
|
1031
|
+
self.venue = get_venue(venue)
|
|
1032
|
+
self.venue_width = width if width is not None else ("text" if venue else None)
|
|
1033
|
+
# physical: units are pt at final print size (paper theme, venue= or width=)
|
|
1034
|
+
self.physical = size is None and (th.physical or venue is not None or width is not None)
|
|
1035
|
+
self.target_width: Optional[float] = (
|
|
1036
|
+
resolve_width_pt(venue, self.venue_width) if self.physical else None)
|
|
1037
|
+
self.global_scale = 1.0 # < 1 when content had to shrink to fit the venue width
|
|
1038
|
+
if panel_scale not in ("fit", "uniform"):
|
|
1039
|
+
raise ValueError("panel_scale must be 'fit' or 'uniform'")
|
|
1040
|
+
self.panel_scale = panel_scale
|
|
1041
|
+
self.title = title
|
|
1042
|
+
self.title_pos = title_pos
|
|
1043
|
+
self.panel_caption = th.panel_caption if panel_caption == "inherit" else panel_caption
|
|
1044
|
+
self.panel_frame = th.panel_frame if panel_frame is None else bool(panel_frame)
|
|
1045
|
+
self.panel_align = panel_align
|
|
1046
|
+
self.panel_justify = panel_justify
|
|
1047
|
+
if size is None:
|
|
1048
|
+
size = "auto" if self.physical else (1600, 1200)
|
|
1049
|
+
self._auto_size = isinstance(size, str) and str(size).lower() == "auto"
|
|
1050
|
+
if self._auto_size:
|
|
1051
|
+
# Placeholder; layout() computes from content + pad (paper crop)
|
|
1052
|
+
self.width, self.height = 800.0, 600.0
|
|
1053
|
+
else:
|
|
1054
|
+
self.width, self.height = float(size[0]), float(size[1]) # type: ignore[index]
|
|
1055
|
+
if equal_heights not in ("row", "all", False):
|
|
1056
|
+
raise ValueError("equal_heights must be 'row', 'all' or False")
|
|
1057
|
+
self.equal_heights = equal_heights
|
|
1058
|
+
self.grid: Optional[List[List[str]]] = None
|
|
1059
|
+
if grid is not None:
|
|
1060
|
+
rows_ = [list(r) for r in grid]
|
|
1061
|
+
if not rows_ or any(len(r) != len(rows_[0]) for r in rows_) or not rows_[0]:
|
|
1062
|
+
raise ValueError("Figure grid rows must be non-empty and equally long")
|
|
1063
|
+
self.grid = rows_
|
|
1064
|
+
cols = len(rows_[0])
|
|
1065
|
+
self._grid_rects = _grid_rects(rows_)
|
|
1066
|
+
self.cols = cols
|
|
1067
|
+
if self.widths is not None and len(self.widths) != cols:
|
|
1068
|
+
raise ValueError(f"widths needs one ratio per grid column ({cols}), got {len(self.widths)}")
|
|
1069
|
+
self.canvas_pad = canvas_pad # None → theme.panel_gap (physical: 1 pt crop margin)
|
|
1070
|
+
self._panels: Dict[str, Panel] = {}
|
|
1071
|
+
self._order: List[str] = []
|
|
1072
|
+
|
|
1073
|
+
def panel(
|
|
1074
|
+
self,
|
|
1075
|
+
letter: str,
|
|
1076
|
+
title: str = "",
|
|
1077
|
+
*,
|
|
1078
|
+
align: Optional[str] = None,
|
|
1079
|
+
justify: Optional[str] = None,
|
|
1080
|
+
pack: str = "center",
|
|
1081
|
+
fit: Optional[bool] = None,
|
|
1082
|
+
fit_mode: Optional[str] = None,
|
|
1083
|
+
pad: Optional[object] = None,
|
|
1084
|
+
overlap: Optional[str] = None,
|
|
1085
|
+
frame: Union[bool, str, None] = None,
|
|
1086
|
+
caption: Optional[str] = "inherit",
|
|
1087
|
+
colspan: int = 1,
|
|
1088
|
+
rowspan: int = 1,
|
|
1089
|
+
max_scale: Optional[float] = None,
|
|
1090
|
+
align_edges: Optional[bool] = None,
|
|
1091
|
+
subtitle: Optional[str] = None,
|
|
1092
|
+
frame_color: Optional[str] = None,
|
|
1093
|
+
letters: Optional[bool] = None,
|
|
1094
|
+
) -> Panel:
|
|
1095
|
+
"""Create (or replace) panel ``letter``.
|
|
1096
|
+
|
|
1097
|
+
Args:
|
|
1098
|
+
letter: Panel letter, shown as ``(a)`` in the caption.
|
|
1099
|
+
title: Caption text.
|
|
1100
|
+
colspan: Grid columns the panel spans (default 1; v0.5.0).
|
|
1101
|
+
rowspan: Grid rows the panel spans (default 1; v0.5.0).
|
|
1102
|
+
align_edges: Nudge nodes so connected ports line up (v0.11.0;
|
|
1103
|
+
default: the figure's ``align_edges``, itself True).
|
|
1104
|
+
max_scale: Largest zoom ``fit_mode="scale"`` may apply (default: the
|
|
1105
|
+
theme's ``max_panel_scale`` — 1.8 classic, 1.0 paper).
|
|
1106
|
+
Use ``1.0`` to keep a wide spanning panel's text at the same size as
|
|
1107
|
+
its neighbours (v0.5.0).
|
|
1108
|
+
align / justify / pack / fit / fit_mode / pad / overlap / frame / caption:
|
|
1109
|
+
Per-panel layout options (see :class:`Panel`).
|
|
1110
|
+
frame: ``True`` / ``"solid"``, ``"dashed"``, ``"dotted"`` or ``False`` — a
|
|
1111
|
+
frame around the whole panel, caption included (v0.13.0 styles);
|
|
1112
|
+
``frame_color=`` colours it, e.g. ``frame="dashed",
|
|
1113
|
+
frame_color=theme.accent("vermillion")``.
|
|
1114
|
+
subtitle: smaller second caption line; wraps to the panel width
|
|
1115
|
+
(v0.13.0). A ``"\n"`` in ``title`` gives a two-line caption.
|
|
1116
|
+
letters: ``False`` drops the ``(a)`` tag but keeps the title (default:
|
|
1117
|
+
``Figure(panel_letters=)``, itself True). ``caption="none"`` hides the
|
|
1118
|
+
whole caption — tag *and* title (a title given then warns).
|
|
1119
|
+
|
|
1120
|
+
With ``Figure(grid=...)`` the span comes from the grid and ``colspan`` /
|
|
1121
|
+
``rowspan`` must be left at 1.
|
|
1122
|
+
"""
|
|
1123
|
+
if int(colspan) < 1 or int(rowspan) < 1:
|
|
1124
|
+
raise ValueError("colspan / rowspan must be >= 1")
|
|
1125
|
+
if self.grid is not None:
|
|
1126
|
+
if (colspan, rowspan) != (1, 1):
|
|
1127
|
+
raise ValueError("with Figure(grid=...) the grid defines spans; drop colspan/rowspan")
|
|
1128
|
+
if letter not in self._grid_rects:
|
|
1129
|
+
raise ValueError(f"panel {letter!r} is not in the figure grid")
|
|
1130
|
+
elif int(colspan) > self.cols:
|
|
1131
|
+
raise ValueError(f"colspan={colspan} exceeds cols={self.cols}")
|
|
1132
|
+
cap = self.panel_caption if caption == "inherit" else caption
|
|
1133
|
+
if title and (cap in (None, False) or str(cap).lower() == "none"):
|
|
1134
|
+
import warnings as _w
|
|
1135
|
+
|
|
1136
|
+
from .lint import CaptionWarning
|
|
1137
|
+
|
|
1138
|
+
_w.warn(f"panel {letter!r}: title {str(title)[:40]!r} is not drawn because the caption "
|
|
1139
|
+
f"is 'none' (it hides the (a) tag AND the title); use "
|
|
1140
|
+
f"Figure(panel_letters=False) / fig.panel(..., letters=False) to drop only the "
|
|
1141
|
+
f"letters, or caption='top'/'bottom'", CaptionWarning, stacklevel=2)
|
|
1142
|
+
p = Panel(
|
|
1143
|
+
letter,
|
|
1144
|
+
title,
|
|
1145
|
+
theme=self.theme,
|
|
1146
|
+
letters=self.panel_letters if letters is None else letters,
|
|
1147
|
+
caption=cap,
|
|
1148
|
+
frame=self.panel_frame if frame is None else frame,
|
|
1149
|
+
align=align or self.panel_align,
|
|
1150
|
+
justify=justify or self.panel_justify,
|
|
1151
|
+
pack=pack,
|
|
1152
|
+
fit=fit,
|
|
1153
|
+
fit_mode=fit_mode,
|
|
1154
|
+
pad=pad,
|
|
1155
|
+
overlap=overlap or self.overlap,
|
|
1156
|
+
max_scale=max_scale,
|
|
1157
|
+
align_edges=self.align_edges if align_edges is None else align_edges,
|
|
1158
|
+
subtitle=subtitle,
|
|
1159
|
+
frame_color=frame_color,
|
|
1160
|
+
)
|
|
1161
|
+
p.colspan, p.rowspan = int(colspan), int(rowspan)
|
|
1162
|
+
p.__dict__["_fig"] = self # Legend(source="figure") collects across panels
|
|
1163
|
+
if letter in self._panels:
|
|
1164
|
+
self._order.remove(letter)
|
|
1165
|
+
self._panels[letter] = p
|
|
1166
|
+
self._order.append(letter)
|
|
1167
|
+
return p
|
|
1168
|
+
|
|
1169
|
+
def _placements(self) -> Tuple[Dict[str, Tuple[int, int, int, int]], int]:
|
|
1170
|
+
"""letter → (row, col, rowspan, colspan); and the number of grid rows."""
|
|
1171
|
+
if self.grid is not None:
|
|
1172
|
+
out = {k: self._grid_rects[k] for k in self._order}
|
|
1173
|
+
return out, len(self.grid)
|
|
1174
|
+
cols = self.cols
|
|
1175
|
+
occ: Dict[Tuple[int, int], str] = {}
|
|
1176
|
+
out = {}
|
|
1177
|
+
for k in self._order:
|
|
1178
|
+
p = self._panels[k]
|
|
1179
|
+
cs, rs = getattr(p, "colspan", 1), getattr(p, "rowspan", 1)
|
|
1180
|
+
r = 0
|
|
1181
|
+
placed = False
|
|
1182
|
+
while not placed:
|
|
1183
|
+
for c in range(cols - cs + 1):
|
|
1184
|
+
if all((r + i, c + j) not in occ for i in range(rs) for j in range(cs)):
|
|
1185
|
+
for i in range(rs):
|
|
1186
|
+
for j in range(cs):
|
|
1187
|
+
occ[(r + i, c + j)] = k
|
|
1188
|
+
out[k] = (r, c, rs, cs)
|
|
1189
|
+
placed = True
|
|
1190
|
+
break
|
|
1191
|
+
r += 1
|
|
1192
|
+
rows = max([r + rs for r, _c, rs, _cs in out.values()] + [1])
|
|
1193
|
+
return out, rows
|
|
1194
|
+
|
|
1195
|
+
def add_panel(self, letter: str, title: str = "", content: Optional[Node] = None) -> Panel:
|
|
1196
|
+
p = self.panel(letter, title)
|
|
1197
|
+
if content is not None:
|
|
1198
|
+
p.set(content)
|
|
1199
|
+
return p
|
|
1200
|
+
|
|
1201
|
+
def _title_h(self) -> float:
|
|
1202
|
+
if self.physical:
|
|
1203
|
+
return self.theme.title_size * 1.25 + self.theme.title_gap if self.title else 0.0
|
|
1204
|
+
return self.theme.title_size * 2.0 if self.title else 12.0
|
|
1205
|
+
|
|
1206
|
+
def _route_constraints(self) -> None:
|
|
1207
|
+
"""Assign Figure-level constraints to the panel whose tree holds their nodes."""
|
|
1208
|
+
for letter in self._order:
|
|
1209
|
+
self._panels[letter]._fig_constraints = []
|
|
1210
|
+
trees = {}
|
|
1211
|
+
for letter in self._order:
|
|
1212
|
+
p = self._panels[letter]
|
|
1213
|
+
ids = {id(n) for n in walk(p.content)} if p.content is not None else set()
|
|
1214
|
+
# place() annotations registered on the panel / its Diagrams belong here too
|
|
1215
|
+
for c in p.all_constraints():
|
|
1216
|
+
if c.overlay is not None:
|
|
1217
|
+
ids |= {id(n) for n in walk(c.overlay)}
|
|
1218
|
+
trees[letter] = ids
|
|
1219
|
+
# place() first so later constraints (e.g. allow_overlap(note)) can name the
|
|
1220
|
+
# annotation it creates
|
|
1221
|
+
fig_cs = list(self._cf_constraints())
|
|
1222
|
+
homes: Dict[int, str] = {}
|
|
1223
|
+
for c in sorted(fig_cs, key=lambda c: c.overlay is None):
|
|
1224
|
+
nodes = [n for n in c.nodes() if c.overlay is None or n is not c.overlay]
|
|
1225
|
+
home = None
|
|
1226
|
+
for letter, ids in trees.items():
|
|
1227
|
+
if nodes and all(id(n) in ids for n in nodes):
|
|
1228
|
+
home = letter
|
|
1229
|
+
break
|
|
1230
|
+
if home is None:
|
|
1231
|
+
from .constraints import ConstraintConflictError, node_name
|
|
1232
|
+
|
|
1233
|
+
raise ConstraintConflictError(
|
|
1234
|
+
f"{c.describe()}: nodes {', '.join(node_name(n) for n in nodes)} are not all "
|
|
1235
|
+
"inside one panel (panels are scaled independently; constraints are per panel)"
|
|
1236
|
+
)
|
|
1237
|
+
if c.overlay is not None:
|
|
1238
|
+
trees[home] |= {id(n) for n in walk(c.overlay)}
|
|
1239
|
+
homes[id(c)] = home
|
|
1240
|
+
for c in fig_cs: # keep the author's order inside each panel
|
|
1241
|
+
self._panels[homes[id(c)]]._fig_constraints.append(c)
|
|
1242
|
+
|
|
1243
|
+
def _policy(self):
|
|
1244
|
+
from .policy import use_policy
|
|
1245
|
+
|
|
1246
|
+
return use_policy(self.roles, self.knockout)
|
|
1247
|
+
|
|
1248
|
+
def categories(self) -> List[str]:
|
|
1249
|
+
"""Distinct ``category=`` values (→ palette index): the ones declared with
|
|
1250
|
+
``Figure(categories=[...])`` or asked for with :meth:`color` first, then the rest in
|
|
1251
|
+
panel order."""
|
|
1252
|
+
out: List[str] = list(self.__dict__.get("_cat_order", []))
|
|
1253
|
+
for letter in self._order:
|
|
1254
|
+
p = self._panels[letter]
|
|
1255
|
+
if p.content is None:
|
|
1256
|
+
continue
|
|
1257
|
+
for n in walk(p.content):
|
|
1258
|
+
c = getattr(n, "category", None)
|
|
1259
|
+
if c is not None and str(c) not in out:
|
|
1260
|
+
out.append(str(c))
|
|
1261
|
+
return out
|
|
1262
|
+
|
|
1263
|
+
def color(self, category: str) -> str:
|
|
1264
|
+
"""The fill colour of ``category`` in this figure (v0.14.0): the same colour a
|
|
1265
|
+
``Box(category=...)`` / ``Circle(category=...)`` gets, so a chart series, an edge
|
|
1266
|
+
or a hand-drawn swatch can share it across panels::
|
|
1267
|
+
|
|
1268
|
+
fig = Figure(venue="osdi", categories=["gate", "baseline"])
|
|
1269
|
+
... Circle("G", category="gate") ... # panel (a)
|
|
1270
|
+
LineChart(..., colors=[fig.color("baseline"), fig.color("gate")]) # panel (b)
|
|
1271
|
+
|
|
1272
|
+
The palette index is fixed on first use (``Figure(categories=)`` order, then calls
|
|
1273
|
+
to ``color()``, then ``category=`` in panel order), so it does not change when
|
|
1274
|
+
later panels add categories. Past the palette (6 paper fills) categories cycle the
|
|
1275
|
+
fills with a hatch; ``color()`` returns the fill only."""
|
|
1276
|
+
name = str(category)
|
|
1277
|
+
if name not in self._cat_order:
|
|
1278
|
+
# keep the indices of categories already used by content stable
|
|
1279
|
+
for c in self.categories():
|
|
1280
|
+
if c not in self._cat_order:
|
|
1281
|
+
self._cat_order.append(c)
|
|
1282
|
+
if name not in self._cat_order:
|
|
1283
|
+
self._cat_order.append(name)
|
|
1284
|
+
from .styles import category_style, use_categories
|
|
1285
|
+
with use_categories(self._cat_order):
|
|
1286
|
+
return category_style(name, self.theme)[0]
|
|
1287
|
+
|
|
1288
|
+
def _ctx(self):
|
|
1289
|
+
"""Theme + typeface + layering policy for everything this figure does."""
|
|
1290
|
+
from contextlib import ExitStack
|
|
1291
|
+
|
|
1292
|
+
from .textutil import use_typeface
|
|
1293
|
+
|
|
1294
|
+
from .styles import use_categories
|
|
1295
|
+
|
|
1296
|
+
st = ExitStack()
|
|
1297
|
+
st.enter_context(use_theme(getattr(self, "_fitted_theme", self.theme)))
|
|
1298
|
+
st.enter_context(use_categories(self.categories()))
|
|
1299
|
+
st.enter_context(use_typeface(self.theme.typeface))
|
|
1300
|
+
st.enter_context(self._policy())
|
|
1301
|
+
from .styles import use_output_scale
|
|
1302
|
+
|
|
1303
|
+
st.enter_context(use_output_scale(self.__dict__.get("_script_k", 1.0)))
|
|
1304
|
+
return st
|
|
1305
|
+
|
|
1306
|
+
def layout(self) -> None:
|
|
1307
|
+
from .constraints import invalidate_tree
|
|
1308
|
+
# Recompute row budgets when a figure is reused at a different width.
|
|
1309
|
+
for panel in self._panels.values():
|
|
1310
|
+
if panel.content is not None:
|
|
1311
|
+
changed = False
|
|
1312
|
+
for node in walk(panel.content):
|
|
1313
|
+
if node.__dict__.pop("_slot_width", None) is not None:
|
|
1314
|
+
changed = True
|
|
1315
|
+
if changed:
|
|
1316
|
+
invalidate_tree(panel.content)
|
|
1317
|
+
self._layout_once()
|
|
1318
|
+
# v0.14.0: wrap long labels that set the width before anything shrinks
|
|
1319
|
+
kept = self.__dict__.setdefault("_autowrapped", [])
|
|
1320
|
+
if self.physical and self.auto_wrap and self.target_width is not None \
|
|
1321
|
+
and self.global_scale < 0.9995:
|
|
1322
|
+
from .autowrap import auto_wrap
|
|
1323
|
+
|
|
1324
|
+
kept.extend(auto_wrap(self, self._layout_once))
|
|
1325
|
+
if self.physical and self.target_width is not None and self.global_scale < 0.9995:
|
|
1326
|
+
self._compress_width_gaps = True
|
|
1327
|
+
self._layout_once()
|
|
1328
|
+
if self.physical and self.auto_wrap and self.global_scale < 0.9:
|
|
1329
|
+
# Use each panel's share of the available slot. Reflow only rows that
|
|
1330
|
+
# exceed that budget; fixed child dimensions remain authoritative.
|
|
1331
|
+
from .constraints import invalidate_tree
|
|
1332
|
+
from .node import Container
|
|
1333
|
+
def reflow(node, budget):
|
|
1334
|
+
if isinstance(node, Container):
|
|
1335
|
+
pt, pr, pb, pl = node.padding
|
|
1336
|
+
budget = max(1.0, budget - pl - pr)
|
|
1337
|
+
if node.direction == "row" and node.measure()[0] > budget + pl + pr:
|
|
1338
|
+
node._slot_width = budget
|
|
1339
|
+
if node.direction == "row" and not node.__dict__.get("_slot_width"):
|
|
1340
|
+
return
|
|
1341
|
+
for child in node.children():
|
|
1342
|
+
reflow(child, budget)
|
|
1343
|
+
for panel in self._panels.values():
|
|
1344
|
+
if panel.content is not None:
|
|
1345
|
+
budget = panel.content.measure()[0] * self.global_scale
|
|
1346
|
+
reflow(panel.content, budget)
|
|
1347
|
+
invalidate_tree(panel.content)
|
|
1348
|
+
self._layout_once()
|
|
1349
|
+
# v0.12.0 (A14): a physical figure shrunk to fit keeps its sub/superscripts at
|
|
1350
|
+
# the theme's printed floor — lay out once more with the scale known
|
|
1351
|
+
for _it in range(3):
|
|
1352
|
+
if not (self.physical and self.theme.script_min > 0):
|
|
1353
|
+
break
|
|
1354
|
+
k = min(1.0, float(self.global_scale))
|
|
1355
|
+
if abs(k - self.__dict__.get("_script_k", 1.0)) <= 0.005:
|
|
1356
|
+
break
|
|
1357
|
+
from .constraints import invalidate_tree
|
|
1358
|
+
|
|
1359
|
+
# a growing script widens the content and lowers the scale: start from
|
|
1360
|
+
# the smaller of the two so the loop settles from below
|
|
1361
|
+
self._script_k = min(k, self.__dict__.get("_script_k", 1.0)) if _it else k
|
|
1362
|
+
for p in self._panels.values():
|
|
1363
|
+
if p.content is not None:
|
|
1364
|
+
invalidate_tree(p.content)
|
|
1365
|
+
self._layout_once()
|
|
1366
|
+
|
|
1367
|
+
def _layout_once(self) -> None:
|
|
1368
|
+
from dataclasses import replace
|
|
1369
|
+
from .constraints import invalidate_tree
|
|
1370
|
+
self.__dict__.pop("_fitted_theme", None)
|
|
1371
|
+
original = self.theme
|
|
1372
|
+
requested_fit = self.fit
|
|
1373
|
+
width_search = self.physical and self.__dict__.get("_compress_width_gaps", False)
|
|
1374
|
+
search = self.physical and (requested_fit in ("compact", "scale") or width_search)
|
|
1375
|
+
def solve(factor):
|
|
1376
|
+
self.theme = replace(original, gap_scale=original.gap_scale * factor,
|
|
1377
|
+
pad_scale=original.pad_scale * factor,
|
|
1378
|
+
box_pad=tuple(v * factor for v in original.box_pad) if original.box_pad else None,
|
|
1379
|
+
corner_radius=original.corner_radius * factor)
|
|
1380
|
+
for panel in self._panels.values():
|
|
1381
|
+
if panel.content is not None:
|
|
1382
|
+
invalidate_tree(panel.content)
|
|
1383
|
+
self._layout_unfitted()
|
|
1384
|
+
if search:
|
|
1385
|
+
self.fit = "compact"
|
|
1386
|
+
try:
|
|
1387
|
+
self._layout_unfitted()
|
|
1388
|
+
if not search:
|
|
1389
|
+
return
|
|
1390
|
+
natural = self._layout_report["natural_size"]
|
|
1391
|
+
target = self.width / self.target_aspect if self.target_aspect else self.target_height
|
|
1392
|
+
if self.max_height is not None:
|
|
1393
|
+
target = min(target, self.max_height) if target is not None else self.max_height
|
|
1394
|
+
chosen = 1.0
|
|
1395
|
+
if width_search and self.global_scale < 0.9995:
|
|
1396
|
+
low, high = 0.6, 1.0
|
|
1397
|
+
solve(low)
|
|
1398
|
+
chosen = low
|
|
1399
|
+
if self.global_scale >= 0.9995:
|
|
1400
|
+
for _ in range(8):
|
|
1401
|
+
mid = (low + high) / 2
|
|
1402
|
+
solve(mid)
|
|
1403
|
+
if self.global_scale >= 0.9995:
|
|
1404
|
+
low = mid
|
|
1405
|
+
else:
|
|
1406
|
+
high = mid
|
|
1407
|
+
chosen = low
|
|
1408
|
+
if target is not None and natural[1] > target:
|
|
1409
|
+
low, high = 0.6, 1.0
|
|
1410
|
+
solve(low)
|
|
1411
|
+
chosen = low
|
|
1412
|
+
if self.height <= target:
|
|
1413
|
+
for _ in range(8):
|
|
1414
|
+
mid = (low + high) / 2
|
|
1415
|
+
solve(mid)
|
|
1416
|
+
if self.height <= target:
|
|
1417
|
+
low = mid
|
|
1418
|
+
else:
|
|
1419
|
+
high = mid
|
|
1420
|
+
chosen = low
|
|
1421
|
+
self.fit = requested_fit
|
|
1422
|
+
solve(chosen)
|
|
1423
|
+
self._layout_report["natural_size"] = natural
|
|
1424
|
+
self._layout_report["gap_scale_used"] = chosen
|
|
1425
|
+
self._fitted_theme = replace(self.theme, fit_font_scale=self.global_scale) if requested_fit == "scale" and self._layout_report["scale_used"] < 1 else self.theme
|
|
1426
|
+
finally:
|
|
1427
|
+
self.fit = requested_fit
|
|
1428
|
+
self.theme = original
|
|
1429
|
+
|
|
1430
|
+
def _layout_unfitted(self) -> None:
|
|
1431
|
+
with self._ctx():
|
|
1432
|
+
self._layout_impl()
|
|
1433
|
+
if self._fit_edge_margins():
|
|
1434
|
+
self._layout_impl()
|
|
1435
|
+
if self.panel_scale == "uniform" and not self.physical:
|
|
1436
|
+
scaled = [p for p in self._panels.values()
|
|
1437
|
+
if p.content is not None and p.fit_mode == "scale"]
|
|
1438
|
+
if scaled:
|
|
1439
|
+
s = min(p._scale for p in scaled)
|
|
1440
|
+
for p in scaled:
|
|
1441
|
+
p._force_scale = s
|
|
1442
|
+
self._layout_impl()
|
|
1443
|
+
for p in scaled:
|
|
1444
|
+
p._force_scale = None
|
|
1445
|
+
|
|
1446
|
+
self._position_corner_legends()
|
|
1447
|
+
|
|
1448
|
+
def _position_corner_legends(self) -> None:
|
|
1449
|
+
from .legend import position_corners
|
|
1450
|
+
for panel in self._panels.values():
|
|
1451
|
+
position_corners(panel)
|
|
1452
|
+
|
|
1453
|
+
def _fit_edge_margins(self) -> bool:
|
|
1454
|
+
"""Size every Diagram's edge margin to what its routed edges / labels really
|
|
1455
|
+
need (instead of the loop-clearance estimate). True if anything changed."""
|
|
1456
|
+
from .constraints import invalidate_tree
|
|
1457
|
+
from .edges import Diagram
|
|
1458
|
+
|
|
1459
|
+
changed = False
|
|
1460
|
+
for letter in self._order:
|
|
1461
|
+
p = self._panels[letter]
|
|
1462
|
+
if p.content is None:
|
|
1463
|
+
continue
|
|
1464
|
+
for n in walk(p.content):
|
|
1465
|
+
if isinstance(n, Diagram) and getattr(n, "_fit_margins", None) is None:
|
|
1466
|
+
cur = n._margins()
|
|
1467
|
+
need = n.edge_overflow()
|
|
1468
|
+
fit = tuple(max(0.0, x) for x in need)
|
|
1469
|
+
if any(abs(a - b) > 1.0 for a, b in zip(cur, fit)):
|
|
1470
|
+
n._fit_margins = fit
|
|
1471
|
+
changed = True
|
|
1472
|
+
if changed:
|
|
1473
|
+
invalidate_tree(p.content)
|
|
1474
|
+
return changed
|
|
1475
|
+
|
|
1476
|
+
def _layout_impl(self) -> None:
|
|
1477
|
+
th = self.theme
|
|
1478
|
+
title_h = self._title_h()
|
|
1479
|
+
cols = self.cols
|
|
1480
|
+
place, rows = self._placements()
|
|
1481
|
+
gap = float(self.canvas_pad if self.canvas_pad is not None else th.panel_gap)
|
|
1482
|
+
self._route_constraints()
|
|
1483
|
+
|
|
1484
|
+
# Natural content sizes per panel
|
|
1485
|
+
needs = {k: self._panels[k].measure_content() for k in self._order}
|
|
1486
|
+
|
|
1487
|
+
def spread(sizes: List[float], start: int, n: int, need: float) -> None:
|
|
1488
|
+
have = sum(sizes[start:start + n]) + gap * (n - 1)
|
|
1489
|
+
if need > have:
|
|
1490
|
+
extra = (need - have) / n
|
|
1491
|
+
for i in range(start, start + n):
|
|
1492
|
+
sizes[i] += extra
|
|
1493
|
+
|
|
1494
|
+
top = 0.0
|
|
1495
|
+
if self.physical:
|
|
1496
|
+
self._layout_physical(needs, place, rows, spread)
|
|
1497
|
+
return
|
|
1498
|
+
if self._auto_size:
|
|
1499
|
+
# Paper crop: size canvas to content + gaps + title.
|
|
1500
|
+
col_w = [0.0] * cols
|
|
1501
|
+
row_h = [0.0] * rows
|
|
1502
|
+
# single-cell panels first, then spans claim what they still lack
|
|
1503
|
+
for multi in (False, True):
|
|
1504
|
+
for k in self._order:
|
|
1505
|
+
r, c, rs, cs = place[k]
|
|
1506
|
+
cw, ch = needs[k]
|
|
1507
|
+
if (cs > 1) == multi:
|
|
1508
|
+
spread(col_w, c, cs, cw)
|
|
1509
|
+
if (rs > 1) == multi:
|
|
1510
|
+
spread(row_h, r, rs, max(ch, 80.0))
|
|
1511
|
+
if self.equal_heights == "all":
|
|
1512
|
+
row_h = [max(row_h)] * rows
|
|
1513
|
+
self.width = sum(col_w) + gap * (cols + 1)
|
|
1514
|
+
title_h = title_h if self.title else 0.0
|
|
1515
|
+
self.height = sum(row_h) + title_h + gap * (rows + 1)
|
|
1516
|
+
top = title_h if self.title_pos == "top" else 0.0
|
|
1517
|
+
else:
|
|
1518
|
+
avail_w = self.width - gap * (cols + 1)
|
|
1519
|
+
avail_h = self.height - title_h - gap * (rows + 1)
|
|
1520
|
+
col_w = [avail_w / cols] * cols
|
|
1521
|
+
# Content-driven row heights: weight each row by max natural content height
|
|
1522
|
+
# so empty air shrinks when some panels are shorter.
|
|
1523
|
+
row_need = [1.0] * rows
|
|
1524
|
+
for multi in (False, True):
|
|
1525
|
+
for k in self._order:
|
|
1526
|
+
r, _c, rs, _cs = place[k]
|
|
1527
|
+
if (rs > 1) == multi:
|
|
1528
|
+
spread(row_need, r, rs, max(needs[k][1], 80.0))
|
|
1529
|
+
equal = avail_h / rows
|
|
1530
|
+
weights = [max(need, equal * 0.35) for need in row_need]
|
|
1531
|
+
if self.equal_heights == "all":
|
|
1532
|
+
weights = [1.0] * rows
|
|
1533
|
+
total = sum(weights) or 1.0
|
|
1534
|
+
row_h = [avail_h * w / total for w in weights]
|
|
1535
|
+
top = title_h if self.title_pos == "top" else 0.0
|
|
1536
|
+
|
|
1537
|
+
col_x = [gap + sum(col_w[:c]) + gap * c for c in range(cols)]
|
|
1538
|
+
row_y = [top + gap + sum(row_h[:r]) + gap * r for r in range(rows)]
|
|
1539
|
+
for k in self._order:
|
|
1540
|
+
r, c, rs, cs = place[k]
|
|
1541
|
+
w = sum(col_w[c:c + cs]) + gap * (cs - 1)
|
|
1542
|
+
h = sum(row_h[r:r + rs]) + gap * (rs - 1)
|
|
1543
|
+
if self.equal_heights is False:
|
|
1544
|
+
h = min(h, needs[k][1])
|
|
1545
|
+
self._panels[k].layout(col_x[c], row_y[r], w, h)
|
|
1546
|
+
|
|
1547
|
+
def _layout_physical(self, needs, place, rows, spread) -> None:
|
|
1548
|
+
"""Print-size layout: panels at natural size (no per-panel zoom). Extra venue
|
|
1549
|
+
width is shared by the columns; if content is wider than the venue, ONE
|
|
1550
|
+
figure-wide scale < 1 shrinks everything uniformly (lint reports it)."""
|
|
1551
|
+
th = self.theme
|
|
1552
|
+
cols = self.cols
|
|
1553
|
+
gap = float(th.panel_gap)
|
|
1554
|
+
margin = float(self.canvas_pad) if self.canvas_pad is not None else 1.0
|
|
1555
|
+
col_w = [0.0] * cols
|
|
1556
|
+
row_h = [0.0] * rows
|
|
1557
|
+
|
|
1558
|
+
def spread_prop(sizes: List[float], start: int, n: int, need: float) -> None:
|
|
1559
|
+
"""v0.7.0: a span's missing size goes to its tracks in proportion to what
|
|
1560
|
+
they already hold, so a short panel beside a tall spanning one stays
|
|
1561
|
+
compact (equal shares when the tracks are still empty)."""
|
|
1562
|
+
have = sum(sizes[start:start + n]) + gap * (n - 1)
|
|
1563
|
+
if need <= have:
|
|
1564
|
+
return
|
|
1565
|
+
extra = need - have
|
|
1566
|
+
cur = sizes[start:start + n]
|
|
1567
|
+
tot = sum(cur)
|
|
1568
|
+
for i in range(n):
|
|
1569
|
+
sizes[start + i] += extra * (cur[i] / tot if tot > 1e-9 else 1.0 / n)
|
|
1570
|
+
|
|
1571
|
+
for multi in (False, True):
|
|
1572
|
+
for k in self._order:
|
|
1573
|
+
r, c, rs, cs = place[k]
|
|
1574
|
+
cw, ch = needs[k]
|
|
1575
|
+
if (cs > 1) == multi:
|
|
1576
|
+
spread_prop(col_w, c, cs, cw)
|
|
1577
|
+
if (rs > 1) == multi:
|
|
1578
|
+
spread_prop(row_h, r, rs, ch)
|
|
1579
|
+
if self.equal_heights == "all":
|
|
1580
|
+
row_h = [max(row_h)] * rows
|
|
1581
|
+
if self.heights is not None and len(self.heights) == rows:
|
|
1582
|
+
k = max(h / r for h, r in zip(row_h, self.heights))
|
|
1583
|
+
row_h = [r * k for r in self.heights]
|
|
1584
|
+
used = [c for c in range(cols) if col_w[c] > 0] or list(range(cols))
|
|
1585
|
+
nat_cols = list(col_w)
|
|
1586
|
+
if self.widths is not None:
|
|
1587
|
+
used = list(range(cols))
|
|
1588
|
+
self._widths_clamped = []
|
|
1589
|
+
if self.target_width is not None:
|
|
1590
|
+
# v0.13.0: widths= are shares of the *slot*. A column whose share is
|
|
1591
|
+
# narrower than its content keeps its natural width and the rest of the
|
|
1592
|
+
# slot is shared among the other columns (CSS-flex style) — the shares
|
|
1593
|
+
# never inflate the figure past the slot and force a global shrink.
|
|
1594
|
+
avail = self.target_width - 2 * margin - gap * (cols - 1)
|
|
1595
|
+
if sum(col_w) >= avail - 1e-6:
|
|
1596
|
+
self._widths_clamped = list(range(cols))
|
|
1597
|
+
else:
|
|
1598
|
+
fixed: dict = {}
|
|
1599
|
+
while True:
|
|
1600
|
+
rest = avail - sum(fixed.values())
|
|
1601
|
+
free = [c for c in range(cols) if c not in fixed]
|
|
1602
|
+
tot = sum(self.widths[c] for c in free) or 1.0
|
|
1603
|
+
bad = [c for c in free if self.widths[c] / tot * rest < col_w[c] - 1e-6]
|
|
1604
|
+
if not bad:
|
|
1605
|
+
break
|
|
1606
|
+
for c in bad:
|
|
1607
|
+
fixed[c] = col_w[c]
|
|
1608
|
+
self._widths_clamped = sorted(fixed)
|
|
1609
|
+
col_w = [fixed[c] if c in fixed else self.widths[c] / tot * rest
|
|
1610
|
+
for c in range(cols)]
|
|
1611
|
+
else:
|
|
1612
|
+
k = max(w / r for w, r in zip(col_w, self.widths)) # every column fits its content
|
|
1613
|
+
col_w = [r * k for r in self.widths]
|
|
1614
|
+
natural_w = sum(col_w) + gap * (len(used) - 1) + 2 * margin
|
|
1615
|
+
self.natural_width = natural_w
|
|
1616
|
+
# v0.13.0: what the natural width is made of (CF-EXP-001 width budget)
|
|
1617
|
+
self._wbudget = {"col_w": list(col_w), "col_nat": list(nat_cols), "gap": gap,
|
|
1618
|
+
"margin": margin, "used": list(used), "needs": dict(needs),
|
|
1619
|
+
"place": dict(place)}
|
|
1620
|
+
s = 1.0
|
|
1621
|
+
if self.target_width is not None:
|
|
1622
|
+
if natural_w <= self.target_width + 1e-6:
|
|
1623
|
+
extra = (self.target_width - natural_w) / max(1, len(used))
|
|
1624
|
+
for c in used:
|
|
1625
|
+
col_w[c] += extra
|
|
1626
|
+
else:
|
|
1627
|
+
s = self.target_width / natural_w
|
|
1628
|
+
self.global_scale = s
|
|
1629
|
+
title_h = self._title_h()
|
|
1630
|
+
top = title_h if (self.title and self.title_pos == "top") else 0.0
|
|
1631
|
+
inner_w = sum(col_w) + gap * (len(used) - 1)
|
|
1632
|
+
col_x = []
|
|
1633
|
+
x = margin
|
|
1634
|
+
for c in range(cols):
|
|
1635
|
+
col_x.append(x)
|
|
1636
|
+
if c in used:
|
|
1637
|
+
x += col_w[c] + gap
|
|
1638
|
+
row_y = [margin + top + sum(row_h[:r]) + gap * r for r in range(rows)]
|
|
1639
|
+
for k in self._order:
|
|
1640
|
+
r, c, rs, cs = place[k]
|
|
1641
|
+
w = sum(col_w[c:c + cs]) + gap * (cs - 1)
|
|
1642
|
+
h = sum(row_h[r:r + rs]) + gap * (rs - 1)
|
|
1643
|
+
if self.equal_heights is False:
|
|
1644
|
+
h = min(h, needs[k][1])
|
|
1645
|
+
self._panels[k].layout(col_x[c], row_y[r], w, h)
|
|
1646
|
+
self._content_w = inner_w + 2 * margin
|
|
1647
|
+
self._content_h = sum(row_h) + gap * (rows - 1) + 2 * margin + title_h
|
|
1648
|
+
self._grid_geom = (col_x, col_w, row_y, row_h, gap, used)
|
|
1649
|
+
self.width = self._content_w * s
|
|
1650
|
+
self.height = self._content_h * s
|
|
1651
|
+
self._layout_report = dict(natural_size=(self.width, self.height),
|
|
1652
|
+
final_size=(self.width, self.height),
|
|
1653
|
+
target_aspect=self.target_aspect,
|
|
1654
|
+
gap_scale_used=1.0, scale_used=1.0)
|
|
1655
|
+
target = self.width / self.target_aspect if self.target_aspect else self.target_height
|
|
1656
|
+
if self.max_height is not None:
|
|
1657
|
+
target = min(target, self.max_height) if target is not None else self.max_height
|
|
1658
|
+
self._height_offset = 0.0
|
|
1659
|
+
if target is not None and self.fit != "none":
|
|
1660
|
+
if self.height > target and self.fit == "scale":
|
|
1661
|
+
factor = target / self.height
|
|
1662
|
+
smallest = min(self.theme.font_sizes or (self.theme.font_size,)) * self.global_scale * factor
|
|
1663
|
+
if smallest < self.min_font:
|
|
1664
|
+
raise LayoutError(f"required scale {factor:.1%}; minimum font {smallest:.2f} pt is below min_font={self.min_font}")
|
|
1665
|
+
self.global_scale *= factor
|
|
1666
|
+
self.height = target
|
|
1667
|
+
self._layout_report["scale_used"] = factor
|
|
1668
|
+
elif self.height < target and self.fit == "pad" and self.max_height is None:
|
|
1669
|
+
self._height_offset = (target - self.height) / (2 * self.global_scale)
|
|
1670
|
+
self.height = target
|
|
1671
|
+
self._layout_report["final_size"] = (self.width, self.height)
|
|
1672
|
+
|
|
1673
|
+
def layout_report(self) -> dict:
|
|
1674
|
+
"""Return a copy of natural and final physical layout diagnostics."""
|
|
1675
|
+
self.layout()
|
|
1676
|
+
return dict(self._layout_report)
|
|
1677
|
+
|
|
1678
|
+
def _draw_dividers(self, buf: List[str]) -> None:
|
|
1679
|
+
"""``panel_dividers=``: a rule centred in each gap between neighbouring panels,
|
|
1680
|
+
as long as the panels on both sides of it."""
|
|
1681
|
+
if not self.panel_dividers or getattr(self, "_grid_geom", None) is None:
|
|
1682
|
+
return
|
|
1683
|
+
from .render_svg import draw_line
|
|
1684
|
+
|
|
1685
|
+
th = self.theme
|
|
1686
|
+
da = {"solid": None, "dashed": "3,2", "dotted": "1,1.5"}[self.panel_dividers]
|
|
1687
|
+
place, _rows = self._placements()
|
|
1688
|
+
col_x, col_w, row_y, row_h, gap, used = self._grid_geom
|
|
1689
|
+
sw = max(th.stroke_width * 0.75, 0.5)
|
|
1690
|
+
for k in self._order:
|
|
1691
|
+
r, c, rs, cs = place[k]
|
|
1692
|
+
g = self._panels[k].geom
|
|
1693
|
+
right = [o for o in self._order if place[o][1] == c + cs and
|
|
1694
|
+
place[o][0] < r + rs and r < place[o][0] + place[o][2]]
|
|
1695
|
+
if right:
|
|
1696
|
+
x = g.x + g.w + gap / 2
|
|
1697
|
+
y0 = max([g.y] + [self._panels[o].geom.y for o in right])
|
|
1698
|
+
y1 = min([g.y + g.h] + [self._panels[o].geom.y + self._panels[o].geom.h for o in right])
|
|
1699
|
+
if y1 > y0:
|
|
1700
|
+
draw_line(buf, x, y0, x, y1, stroke=th.muted_color, stroke_width=sw, dasharray=da)
|
|
1701
|
+
below = [o for o in self._order if place[o][0] == r + rs and
|
|
1702
|
+
place[o][1] < c + cs and c < place[o][1] + place[o][3]]
|
|
1703
|
+
if below:
|
|
1704
|
+
y = g.y + g.h + gap / 2
|
|
1705
|
+
x0 = max([g.x] + [self._panels[o].geom.x for o in below])
|
|
1706
|
+
x1 = min([g.x + g.w] + [self._panels[o].geom.x + self._panels[o].geom.w for o in below])
|
|
1707
|
+
if x1 > x0:
|
|
1708
|
+
draw_line(buf, x0, y, x1, y, stroke=th.muted_color, stroke_width=sw, dasharray=da)
|
|
1709
|
+
|
|
1710
|
+
def check_overlaps(self) -> list:
|
|
1711
|
+
"""Lay out (if needed) and return every remaining overlap, all panels.
|
|
1712
|
+
Each item names the two nodes, the panel and the penetration in px."""
|
|
1713
|
+
import warnings
|
|
1714
|
+
|
|
1715
|
+
from .overlaps import OverlapWarning
|
|
1716
|
+
|
|
1717
|
+
with warnings.catch_warnings():
|
|
1718
|
+
warnings.simplefilter("ignore", OverlapWarning)
|
|
1719
|
+
self.layout()
|
|
1720
|
+
out = []
|
|
1721
|
+
with self._ctx():
|
|
1722
|
+
for letter in self._order:
|
|
1723
|
+
out.extend(self._panels[letter].check_overlaps())
|
|
1724
|
+
return out
|
|
1725
|
+
|
|
1726
|
+
def to_svg(self, *, alt: Optional[str] = None) -> str:
|
|
1727
|
+
"""SVG text. Physical figures carry ``width="…pt"`` (1 user unit = 1 pt)."""
|
|
1728
|
+
with self._ctx():
|
|
1729
|
+
return self._to_svg_impl(alt=alt)
|
|
1730
|
+
|
|
1731
|
+
def _to_svg_impl(self, alt: Optional[str] = None) -> str:
|
|
1732
|
+
self.layout()
|
|
1733
|
+
th = self.theme
|
|
1734
|
+
buf = svg_header(self.width, self.height, th, units="pt" if self.physical else "",
|
|
1735
|
+
title=alt)
|
|
1736
|
+
if self.physical:
|
|
1737
|
+
if self.global_scale < 0.9999:
|
|
1738
|
+
buf.append(f'<g transform="scale({self.global_scale:.5f})">')
|
|
1739
|
+
if getattr(self, "_height_offset", 0):
|
|
1740
|
+
buf.append(f'<g transform="translate(0,{self._height_offset:.5f})">')
|
|
1741
|
+
if self.title:
|
|
1742
|
+
tw, _ = measure_text(self.title, th.title_size, weight="bold")
|
|
1743
|
+
cw, ch = self._content_w, self._content_h
|
|
1744
|
+
ty = (1.0 + th.title_size) if self.title_pos == "top" else ch - 1.0 - th.title_size * 0.3
|
|
1745
|
+
draw_text(buf, self.title, (cw - tw) / 2, ty, font_size=th.title_size,
|
|
1746
|
+
weight="bold")
|
|
1747
|
+
self._draw_dividers(buf)
|
|
1748
|
+
for letter in self._order:
|
|
1749
|
+
self._panels[letter].draw(buf)
|
|
1750
|
+
if getattr(self, "_height_offset", 0):
|
|
1751
|
+
buf.append("</g>")
|
|
1752
|
+
if self.global_scale < 0.9999:
|
|
1753
|
+
buf.append("</g>")
|
|
1754
|
+
buf.extend(svg_footer())
|
|
1755
|
+
return "\n".join(buf)
|
|
1756
|
+
if self.title:
|
|
1757
|
+
tw, _ = measure_text(self.title, th.title_size)
|
|
1758
|
+
ty = th.title_size + 8 if self.title_pos == "top" else self.height - th.title_size * 0.7
|
|
1759
|
+
draw_text(
|
|
1760
|
+
buf,
|
|
1761
|
+
self.title,
|
|
1762
|
+
(self.width - tw) / 2,
|
|
1763
|
+
ty,
|
|
1764
|
+
font_size=th.title_size,
|
|
1765
|
+
weight="bold",
|
|
1766
|
+
)
|
|
1767
|
+
for letter in self._order:
|
|
1768
|
+
self._panels[letter].draw(buf)
|
|
1769
|
+
buf.extend(svg_footer())
|
|
1770
|
+
return "\n".join(buf)
|
|
1771
|
+
|
|
1772
|
+
def save(
|
|
1773
|
+
self,
|
|
1774
|
+
path: Union[str, Path],
|
|
1775
|
+
*,
|
|
1776
|
+
scale: Optional[float] = None,
|
|
1777
|
+
dpi: Optional[float] = None,
|
|
1778
|
+
lint: Optional[str] = "auto",
|
|
1779
|
+
alt: Optional[str] = None,
|
|
1780
|
+
venue: Optional[str] = None,
|
|
1781
|
+
) -> Path:
|
|
1782
|
+
"""Save as ``.svg``, ``.png`` or ``.pdf``.
|
|
1783
|
+
|
|
1784
|
+
Args:
|
|
1785
|
+
scale: PNG pixel density for px figures (2 → 2×).
|
|
1786
|
+
dpi: PNG resolution for physical (pt) figures (default 300).
|
|
1787
|
+
lint: ``"error"`` refuses to write when :meth:`lint` finds errors (the
|
|
1788
|
+
LintError's last line summarises each error with its fix and the preview
|
|
1789
|
+
path), ``"warn"`` writes anyway, emits a :class:`~codefig.lint.LintWarning`
|
|
1790
|
+
per finding and writes ``<stem>.lint_preview.png`` when there are errors
|
|
1791
|
+
(the iteration mode; see also :meth:`preview`),
|
|
1792
|
+
``None`` / ``"off"`` skips it; ``"auto"`` (default) = ``"error"`` for
|
|
1793
|
+
physical figures, off for legacy px figures.
|
|
1794
|
+
alt: Alt text: written as the SVG ``<title>`` and to ``<path>.alt.txt``.
|
|
1795
|
+
venue: Venue for the save-time lint (default: the figure's venue).
|
|
1796
|
+
"""
|
|
1797
|
+
from .export import save_figure
|
|
1798
|
+
|
|
1799
|
+
return save_figure(self, path, scale=scale, dpi=dpi, lint=lint, alt=alt, venue=venue)
|
|
1800
|
+
|
|
1801
|
+
def preview(
|
|
1802
|
+
self,
|
|
1803
|
+
path: Union[str, Path] = "fig.png",
|
|
1804
|
+
*,
|
|
1805
|
+
alt: Optional[str] = None,
|
|
1806
|
+
venue: Optional[str] = None,
|
|
1807
|
+
scale: Optional[float] = None,
|
|
1808
|
+
dpi: Optional[float] = None,
|
|
1809
|
+
) -> "Any":
|
|
1810
|
+
"""Iteration helper: lint, **always** write the figure, never raise.
|
|
1811
|
+
|
|
1812
|
+
Writes ``path`` (``.png`` / ``.svg`` / ``.pdf``) even when lint finds errors, plus
|
|
1813
|
+
``<stem>.lint_preview.png`` with every error / warning outlined when there are any
|
|
1814
|
+
(a stale preview is removed when the figure is clean). Returns the
|
|
1815
|
+
:class:`~codefig.lint.LintReport` (``report.image`` / ``report.preview`` are the
|
|
1816
|
+
paths), so ``print(fig.preview("fig.png"))`` shows every finding with its fix.
|
|
1817
|
+
Use it while iterating; make the final call ``fig.save(path, alt=...)``, which
|
|
1818
|
+
refuses on errors.
|
|
1819
|
+
"""
|
|
1820
|
+
from .export import save_figure, write_lint_preview
|
|
1821
|
+
|
|
1822
|
+
path = Path(path)
|
|
1823
|
+
rep = self.lint(venue=venue, alt=alt)
|
|
1824
|
+
save_figure(self, path, scale=scale, dpi=dpi, lint=None, alt=alt, venue=venue)
|
|
1825
|
+
rep.image = str(path)
|
|
1826
|
+
if any(f.severity in ("error", "warn") for f in rep.findings):
|
|
1827
|
+
prev = write_lint_preview(self, rep, path, alt)
|
|
1828
|
+
rep.preview = str(prev) if prev is not None else None
|
|
1829
|
+
return rep
|
|
1830
|
+
|
|
1831
|
+
def lint(
|
|
1832
|
+
self,
|
|
1833
|
+
venue: Optional[str] = None,
|
|
1834
|
+
width: Union[str, float, None] = None,
|
|
1835
|
+
scale: float = 1.0,
|
|
1836
|
+
ignore: Optional[Sequence[str]] = None,
|
|
1837
|
+
thresholds: Optional[Dict[str, Dict[str, float]]] = None,
|
|
1838
|
+
*,
|
|
1839
|
+
pdf: Optional[Union[str, Path]] = None,
|
|
1840
|
+
alt: Optional[str] = None,
|
|
1841
|
+
) -> "Any":
|
|
1842
|
+
"""Check the figure against the publication style rules → :class:`~codefig.lint.LintReport`.
|
|
1843
|
+
|
|
1844
|
+
``venue`` / ``width`` default to the figure's own; ``scale`` is the LaTeX
|
|
1845
|
+
``\\includegraphics`` scale (e.g. 0.8). See ``docs/lint-rules.md``.
|
|
1846
|
+
"""
|
|
1847
|
+
from .lint import lint_figure
|
|
1848
|
+
|
|
1849
|
+
return lint_figure(self, venue=venue, width=width, scale=scale, ignore=ignore,
|
|
1850
|
+
thresholds=thresholds, pdf=pdf, alt=alt)
|
|
1851
|
+
|
|
1852
|
+
|
|
1853
|
+
def _seg_hits_rect(ax: float, ay: float, bx: float, by: float,
|
|
1854
|
+
rx: float, ry: float, rw: float, rh: float, inset: float = 1.0) -> bool:
|
|
1855
|
+
"""Axis-aligned segment passes through the (inset) rectangle interior."""
|
|
1856
|
+
x0, x1 = sorted((ax, bx))
|
|
1857
|
+
y0, y1 = sorted((ay, by))
|
|
1858
|
+
return (x0 < rx + rw - inset and x1 > rx + inset and
|
|
1859
|
+
y0 < ry + rh - inset and y1 > ry + inset)
|
|
1860
|
+
|
|
1861
|
+
|
|
1862
|
+
class _Reorder:
|
|
1863
|
+
"""Move one child of a container to index ``to`` (undoable)."""
|
|
1864
|
+
|
|
1865
|
+
def __init__(self, cont: Any, unit: Any, to: int, where: str) -> None:
|
|
1866
|
+
self.cont, self.unit, self.to, self.where = cont, unit, to, where
|
|
1867
|
+
self._old: Optional[list] = None
|
|
1868
|
+
|
|
1869
|
+
def apply(self) -> None:
|
|
1870
|
+
kids = self.cont._kids
|
|
1871
|
+
self._old = list(kids)
|
|
1872
|
+
kids.remove(self.unit)
|
|
1873
|
+
kids.insert(self.to, self.unit)
|
|
1874
|
+
self.cont.invalidate()
|
|
1875
|
+
|
|
1876
|
+
def undo(self) -> None:
|
|
1877
|
+
if self._old is not None:
|
|
1878
|
+
self.cont._kids[:] = self._old
|
|
1879
|
+
self._old = None
|
|
1880
|
+
self.cont.invalidate()
|
|
1881
|
+
|
|
1882
|
+
|
|
1883
|
+
def _reorder_moves(root: Any, region: Any, intruder: Any) -> list:
|
|
1884
|
+
"""Candidate sibling reorders that take ``intruder`` out of ``region``'s span."""
|
|
1885
|
+
from .node import Container
|
|
1886
|
+
|
|
1887
|
+
parent: dict = {}
|
|
1888
|
+
for n in walk(root):
|
|
1889
|
+
for k in n.children():
|
|
1890
|
+
parent[id(k)] = n
|
|
1891
|
+
|
|
1892
|
+
def chain(n: Any) -> list:
|
|
1893
|
+
out = [n]
|
|
1894
|
+
while id(out[-1]) in parent:
|
|
1895
|
+
out.append(parent[id(out[-1])])
|
|
1896
|
+
return out
|
|
1897
|
+
|
|
1898
|
+
members = list(region.members)
|
|
1899
|
+
if not members:
|
|
1900
|
+
return []
|
|
1901
|
+
ichain = chain(intruder)
|
|
1902
|
+
mchains = [chain(m) for m in members]
|
|
1903
|
+
for depth in range(1, len(ichain)):
|
|
1904
|
+
cont = ichain[depth]
|
|
1905
|
+
if not all(any(a is cont for a in mc) for mc in mchains):
|
|
1906
|
+
continue
|
|
1907
|
+
if not isinstance(cont, Container) or not hasattr(cont, "_kids"):
|
|
1908
|
+
return []
|
|
1909
|
+
unit = ichain[depth - 1]
|
|
1910
|
+
kids = list(cont._kids)
|
|
1911
|
+
mpos = []
|
|
1912
|
+
for mc in mchains:
|
|
1913
|
+
i = next(j for j, a in enumerate(mc) if a is cont)
|
|
1914
|
+
mpos.append(kids.index(mc[i - 1]) if i > 0 else -1)
|
|
1915
|
+
if -1 in mpos or any(kids[p] is unit for p in mpos):
|
|
1916
|
+
return [] # a member *is* the container, or shares the intruder's unit
|
|
1917
|
+
cur = kids.index(unit)
|
|
1918
|
+
lo, hi = min(mpos), max(mpos)
|
|
1919
|
+
if not lo < cur < hi:
|
|
1920
|
+
return []
|
|
1921
|
+
moves = [_Reorder(cont, unit, lo, "before"), _Reorder(cont, unit, hi, "after")]
|
|
1922
|
+
return moves
|
|
1923
|
+
return []
|