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/legend.py
ADDED
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
"""Legends: keys for fills, hatches, borders and edge styles.
|
|
2
|
+
|
|
3
|
+
``Legend()`` with no entries auto-collects every ``legend="…"`` label in its
|
|
4
|
+
panel: nodes (their fill / pattern / border), edges and forks (colour, dash,
|
|
5
|
+
arrowhead) and Matrix ``legend={colour: label}`` maps. Pass ``entries=[Key(…)]``
|
|
6
|
+
to list keys by hand (or ``auto=False`` to use only those). A Legend is an
|
|
7
|
+
ordinary node: put it in a Row/Col or ``place()`` it next to anything.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, List, Optional, Sequence, Tuple
|
|
13
|
+
|
|
14
|
+
from .node import Node
|
|
15
|
+
from .styles import themed
|
|
16
|
+
from .textutil import measure_text
|
|
17
|
+
|
|
18
|
+
KINDS = ("box", "line", "arrow", "dot", "mark", "icon")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Hatch:
|
|
23
|
+
"""Diagonal hatching pattern for ``pattern=`` on Box / Group / Key.
|
|
24
|
+
|
|
25
|
+
angle: degrees (45 = "/", -45 = "\\", 0 = horizontal, 90 = vertical);
|
|
26
|
+
``cross=True`` adds the perpendicular set.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, *, angle: float = 45.0, spacing: Optional[float] = None,
|
|
30
|
+
color: str = "#555555", width: Optional[float] = None, opacity: float = 1.0,
|
|
31
|
+
cross: bool = False) -> None:
|
|
32
|
+
self.angle = float(angle)
|
|
33
|
+
self._spacing = None if spacing is None else float(spacing)
|
|
34
|
+
self.color = color
|
|
35
|
+
self._width = None if width is None else float(width)
|
|
36
|
+
self.opacity = float(opacity)
|
|
37
|
+
self.cross = bool(cross)
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def spacing(self) -> float:
|
|
41
|
+
"""Line pitch (default ``theme.hatch_spacing``: 5 px classic, 2.8 pt paper)."""
|
|
42
|
+
if self._spacing is not None:
|
|
43
|
+
return self._spacing
|
|
44
|
+
from .styles import current_theme
|
|
45
|
+
|
|
46
|
+
return float(current_theme().hatch_spacing)
|
|
47
|
+
|
|
48
|
+
@spacing.setter
|
|
49
|
+
def spacing(self, v: Optional[float]) -> None:
|
|
50
|
+
self._spacing = None if v is None else float(v)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def width(self) -> float:
|
|
54
|
+
"""Line width (default ``theme.hatch_width``: 0.8 px classic, 0.5 pt paper)."""
|
|
55
|
+
if self._width is not None:
|
|
56
|
+
return self._width
|
|
57
|
+
from .styles import current_theme
|
|
58
|
+
|
|
59
|
+
return float(current_theme().hatch_width)
|
|
60
|
+
|
|
61
|
+
@width.setter
|
|
62
|
+
def width(self, v: Optional[float]) -> None:
|
|
63
|
+
self._width = None if v is None else float(v)
|
|
64
|
+
|
|
65
|
+
_seq = 0
|
|
66
|
+
|
|
67
|
+
def paint(self, buf: List[str], x: float, y: float, w: float, h: float) -> None:
|
|
68
|
+
import math
|
|
69
|
+
|
|
70
|
+
Hatch._seq += 1
|
|
71
|
+
cid = f"cf-hatch-{Hatch._seq}"
|
|
72
|
+
buf.append(f'<defs><clipPath id="{cid}"><rect x="{x:.2f}" y="{y:.2f}" width="{w:.2f}" '
|
|
73
|
+
f'height="{h:.2f}"/></clipPath></defs>')
|
|
74
|
+
op = f' stroke-opacity="{self.opacity:.3g}"' if self.opacity < 1 else ""
|
|
75
|
+
segs = []
|
|
76
|
+
cx, cy = x + w / 2, y + h / 2
|
|
77
|
+
R = math.hypot(w, h) / 2 + self.spacing
|
|
78
|
+
for ang in ([self.angle, self.angle + 90] if self.cross else [self.angle]):
|
|
79
|
+
t = math.radians(-ang)
|
|
80
|
+
dx, dy = math.cos(t), math.sin(t) # line direction
|
|
81
|
+
nx, ny = -dy, dx # normal
|
|
82
|
+
n = int(R / self.spacing) + 1
|
|
83
|
+
for k in range(-n, n + 1):
|
|
84
|
+
ox, oy = cx + nx * k * self.spacing, cy + ny * k * self.spacing
|
|
85
|
+
segs.append(f"M {ox - dx * R:.2f},{oy - dy * R:.2f} L {ox + dx * R:.2f},{oy + dy * R:.2f}")
|
|
86
|
+
buf.append(f'<path d="{" ".join(segs)}" stroke="{self.color}" stroke-width="{self.width:.3g}"'
|
|
87
|
+
f' fill="none" clip-path="url(#{cid})"{op} />')
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class Key:
|
|
91
|
+
"""One legend entry.
|
|
92
|
+
|
|
93
|
+
kind: one of ``"box"`` (fill / pattern / border / hatch), ``"line"`` or ``"arrow"``
|
|
94
|
+
(edge ``color=``, ``style="dashed"|"dotted"|…``, ``width=``, ``head=`` / ``tail=``),
|
|
95
|
+
``"dot"``, ``"mark"`` (``mark="check"|"cross"|…`` glyph) or ``"icon"`` (``icon=``).
|
|
96
|
+
Unspecified kind is inferred (``style=`` / ``head=`` → line / arrow, ``mark=`` →
|
|
97
|
+
mark, ``icon=`` → icon, else box). Labels may contain ``"\n"``: the row grows.
|
|
98
|
+
Example: ``Key("copy", kind="arrow", color="#0072B2", style="dashed")``.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def __init__(self, label: str, *, kind: Optional[str] = None, fill: Optional[str] = None,
|
|
102
|
+
stroke: Optional[str] = None, border: Any = None, pattern: Any = None,
|
|
103
|
+
hatch: Any = None, color: Optional[str] = None, style: Any = None,
|
|
104
|
+
width: Optional[float] = None, head: Any = None, tail: Any = None,
|
|
105
|
+
hollow: Optional[bool] = None, mark: Optional[str] = None,
|
|
106
|
+
radius: float = 1.5, icon: Any = None) -> None:
|
|
107
|
+
if hatch is not None and pattern is None:
|
|
108
|
+
pattern = hatch if not isinstance(hatch, (bool, int, float, str)) else Hatch(
|
|
109
|
+
angle=45.0 if hatch in (True, "/", "diag") else (
|
|
110
|
+
-45.0 if hatch == "\\" else float(hatch) if isinstance(hatch, (int, float))
|
|
111
|
+
else 45.0))
|
|
112
|
+
if icon is not None and not hasattr(icon, "paint"):
|
|
113
|
+
from .icon import as_icon
|
|
114
|
+
|
|
115
|
+
icon = as_icon(icon, color=color)
|
|
116
|
+
if kind is None:
|
|
117
|
+
if icon is not None:
|
|
118
|
+
kind = "icon"
|
|
119
|
+
elif mark is not None:
|
|
120
|
+
kind = "mark"
|
|
121
|
+
elif head is not None or tail is not None:
|
|
122
|
+
kind = "arrow"
|
|
123
|
+
elif style is not None and fill is None and pattern is None:
|
|
124
|
+
kind = "line"
|
|
125
|
+
else:
|
|
126
|
+
kind = "box"
|
|
127
|
+
if kind not in KINDS:
|
|
128
|
+
raise ValueError(f"Key kind must be one of {KINDS}, got {kind!r}")
|
|
129
|
+
self.label = label
|
|
130
|
+
self.kind = kind
|
|
131
|
+
self.fill = fill
|
|
132
|
+
self.stroke = stroke
|
|
133
|
+
self.border = border
|
|
134
|
+
self.pattern = pattern
|
|
135
|
+
self.color = color
|
|
136
|
+
self.style = style
|
|
137
|
+
self.width = width
|
|
138
|
+
self.head = head
|
|
139
|
+
self.tail = tail
|
|
140
|
+
self.hollow = hollow
|
|
141
|
+
self.mark = mark
|
|
142
|
+
self.radius = radius
|
|
143
|
+
self.icon = icon
|
|
144
|
+
|
|
145
|
+
def _sig(self) -> Tuple:
|
|
146
|
+
return (self.label, self.kind, self.fill, self.color, str(self.style), str(self.border),
|
|
147
|
+
type(self.pattern).__name__, str(self.head), getattr(self.icon, "name", None))
|
|
148
|
+
|
|
149
|
+
@classmethod
|
|
150
|
+
def from_node(cls, node: Any, label: str) -> "Key":
|
|
151
|
+
fill = getattr(node, "fill", None)
|
|
152
|
+
stroke = getattr(node, "stroke", None)
|
|
153
|
+
return cls(label, kind="box", fill=fill if fill not in (None, "none") else "#ffffff",
|
|
154
|
+
stroke=stroke, border=getattr(node, "border", None),
|
|
155
|
+
pattern=getattr(node, "pattern", None))
|
|
156
|
+
|
|
157
|
+
@classmethod
|
|
158
|
+
def from_edge(cls, edge: Any, label: str) -> "Key":
|
|
159
|
+
st = edge.resolved()
|
|
160
|
+
head = st.end_head.shape if st.end_head is not None and st.end_head.visible else "none"
|
|
161
|
+
return cls(label, kind="arrow" if head != "none" else "line", color=st.color,
|
|
162
|
+
style=st.dash or st.style, width=st.width, head=head,
|
|
163
|
+
hollow=st.end_head.hollow if st.end_head is not None else None)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def collect_keys(root: Any) -> List[Key]:
|
|
167
|
+
"""Keys from every ``legend=`` in ``root``'s tree (nodes, Matrix maps, edges, forks)."""
|
|
168
|
+
from .constraints import walk
|
|
169
|
+
from .edges import Diagram, Edge, Fork
|
|
170
|
+
|
|
171
|
+
keys: List[Key] = []
|
|
172
|
+
seen = set()
|
|
173
|
+
|
|
174
|
+
def add(k: Key) -> None:
|
|
175
|
+
if k.label in seen:
|
|
176
|
+
return
|
|
177
|
+
seen.add(k.label)
|
|
178
|
+
keys.append(k)
|
|
179
|
+
|
|
180
|
+
for n in walk(root):
|
|
181
|
+
if isinstance(n, Legend):
|
|
182
|
+
continue
|
|
183
|
+
if hasattr(n, "legend_entries") and getattr(n, "legend", None):
|
|
184
|
+
for k in n.legend_entries():
|
|
185
|
+
add(k)
|
|
186
|
+
elif isinstance(getattr(n, "legend", None), str) and not isinstance(n, Diagram):
|
|
187
|
+
add(n.legend_key(n.legend) if hasattr(n, "legend_key") else Key.from_node(n, n.legend))
|
|
188
|
+
elif getattr(n, "category", None) is not None and not isinstance(n, Diagram):
|
|
189
|
+
add(Key.from_node(n, str(n.category)))
|
|
190
|
+
if isinstance(n, Diagram):
|
|
191
|
+
for e in n.edges:
|
|
192
|
+
lab = getattr(e, "legend", None)
|
|
193
|
+
if not lab:
|
|
194
|
+
continue
|
|
195
|
+
if isinstance(e, Edge):
|
|
196
|
+
add(Key.from_edge(e, lab))
|
|
197
|
+
elif isinstance(e, Fork):
|
|
198
|
+
probe = Edge(e._node(e.hub), e._node(e.leaves[0]), **e._style_kw())
|
|
199
|
+
add(Key.from_edge(probe, lab))
|
|
200
|
+
order = {"box": 0, "icon": 1, "mark": 1, "dot": 1, "line": 2, "arrow": 2}
|
|
201
|
+
return sorted(keys, key=lambda k: order[k.kind])
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
CORNERS = ("top-right", "top-left", "bottom-right", "bottom-left")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class Legend(Node):
|
|
208
|
+
"""Legend box: swatch + label per key, in ``cols`` columns (or ``direction='row'``).
|
|
209
|
+
|
|
210
|
+
entries: explicit keys (``Key`` or ``(label, fill)`` tuples); ``auto`` (default: only
|
|
211
|
+
when no entries are given; ``auto=True`` to combine) collects ``legend=`` labels and
|
|
212
|
+
``category=`` names from ``source`` (default: the panel it sits in; a node; or
|
|
213
|
+
``source="figure"`` for one shared legend over every panel, v0.14.0).
|
|
214
|
+
``corner`` places the legend inside ``of`` (default: panel content), inset by
|
|
215
|
+
``inset`` points. ``auto`` chooses a clear corner. ``overlay=True`` excludes
|
|
216
|
+
corner legends from natural sizing; None preserves ordinary layout.
|
|
217
|
+
Swatch size, gaps and padding default to the theme (20×11 px classic).
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
swatch = themed((20.0, 11.0))
|
|
221
|
+
gap = themed(6.0)
|
|
222
|
+
item_gap = themed(12.0)
|
|
223
|
+
stroke_width = themed(0.8, "SW")
|
|
224
|
+
box_pad = themed(5.0)
|
|
225
|
+
row_gap = themed(3.0)
|
|
226
|
+
|
|
227
|
+
def __init__(self, entries: Optional[Sequence[Any]] = None, *, auto: Optional[bool] = None,
|
|
228
|
+
title: Optional[str] = None, cols: Optional[int] = None,
|
|
229
|
+
direction: str = "col", font_size: Optional[float] = None,
|
|
230
|
+
swatch: Optional[Tuple[float, float]] = None, gap: Optional[float] = None,
|
|
231
|
+
item_gap: Optional[float] = None, fill: str = "none",
|
|
232
|
+
stroke: Optional[str] = None, stroke_width: Optional[float] = None,
|
|
233
|
+
pad: Optional[float] = None, source: Any = None,
|
|
234
|
+
corner: Optional[str] = None, of: Any = None,
|
|
235
|
+
inset: float = 6.0, overlay: bool = True,
|
|
236
|
+
**kwargs: Any) -> None:
|
|
237
|
+
if corner not in (None, "auto", *CORNERS):
|
|
238
|
+
raise ValueError(f"Legend corner must be None, auto, {', '.join(CORNERS)}; got {corner!r}")
|
|
239
|
+
self.corner, self.of, self.inset, self.overlay = corner, of, float(inset), overlay
|
|
240
|
+
if direction not in ("col", "row"):
|
|
241
|
+
raise ValueError("Legend direction must be col|row")
|
|
242
|
+
super().__init__(**kwargs)
|
|
243
|
+
self.entries = [self._as_key(e) for e in (entries or [])]
|
|
244
|
+
# auto-collect unless keys were given explicitly (pass auto=True for both)
|
|
245
|
+
self.auto = (entries is None) if auto is None else bool(auto)
|
|
246
|
+
self.title = title
|
|
247
|
+
self.cols = cols
|
|
248
|
+
self.direction = direction
|
|
249
|
+
self.font_size = font_size
|
|
250
|
+
self.swatch = None if swatch is None else (float(swatch[0]), float(swatch[1]))
|
|
251
|
+
self.gap = gap
|
|
252
|
+
self.item_gap = item_gap
|
|
253
|
+
self.fill = fill
|
|
254
|
+
self.stroke = stroke
|
|
255
|
+
self.stroke_width = stroke_width
|
|
256
|
+
self.box_pad = pad
|
|
257
|
+
if isinstance(source, str) and source != "figure":
|
|
258
|
+
raise ValueError('Legend source= must be a node, a list of nodes or "figure"')
|
|
259
|
+
self.source = source
|
|
260
|
+
self._collected: List[Key] = []
|
|
261
|
+
|
|
262
|
+
@staticmethod
|
|
263
|
+
def _as_key(e: Any) -> Key:
|
|
264
|
+
if isinstance(e, Key):
|
|
265
|
+
return e
|
|
266
|
+
if isinstance(e, (tuple, list)) and len(e) == 2:
|
|
267
|
+
return Key(str(e[0]), fill=e[1])
|
|
268
|
+
raise TypeError("Legend entries must be Key or (label, fill) pairs")
|
|
269
|
+
|
|
270
|
+
def collect(self, root: Any) -> None:
|
|
271
|
+
"""Gather auto keys from ``source`` (or ``root``); called before measuring."""
|
|
272
|
+
if not self.auto:
|
|
273
|
+
return
|
|
274
|
+
src = self.source if self.source is not None and not isinstance(self.source, str) else root
|
|
275
|
+
if isinstance(src, (list, tuple)):
|
|
276
|
+
keys, seen = [], set()
|
|
277
|
+
for r in src:
|
|
278
|
+
for k in collect_keys(r):
|
|
279
|
+
if k.label not in seen:
|
|
280
|
+
seen.add(k.label)
|
|
281
|
+
keys.append(k)
|
|
282
|
+
else:
|
|
283
|
+
keys = collect_keys(src)
|
|
284
|
+
have = {k.label for k in self.entries}
|
|
285
|
+
new = [k for k in keys if k.label not in have]
|
|
286
|
+
if [k._sig() for k in new] != [k._sig() for k in self._collected]:
|
|
287
|
+
self._collected = new
|
|
288
|
+
self._measured = None
|
|
289
|
+
|
|
290
|
+
def keys(self) -> List[Key]:
|
|
291
|
+
return list(self.entries) + list(self._collected)
|
|
292
|
+
|
|
293
|
+
def _fs(self) -> float:
|
|
294
|
+
return self.font_size if self.font_size is not None else self.theme.font_size * 0.85
|
|
295
|
+
|
|
296
|
+
def _ncols(self) -> int:
|
|
297
|
+
n = max(1, len(self.keys()))
|
|
298
|
+
if self.cols:
|
|
299
|
+
return max(1, min(int(self.cols), n))
|
|
300
|
+
return n if self.direction == "row" else 1
|
|
301
|
+
|
|
302
|
+
def _cells(self) -> Tuple[List[float], float, float]:
|
|
303
|
+
col_w, rows_h, title_h = self._cells_rows()
|
|
304
|
+
return col_w, max(rows_h, default=0.0), title_h
|
|
305
|
+
|
|
306
|
+
def _cells_rows(self) -> Tuple[List[float], List[float], float]:
|
|
307
|
+
"""Column widths, per-row heights (v0.13.0: a ``"\n"`` label makes its row
|
|
308
|
+
taller instead of running into the next one) and the title height."""
|
|
309
|
+
fs = self._fs()
|
|
310
|
+
keys = self.keys()
|
|
311
|
+
nc = self._ncols()
|
|
312
|
+
sw, sh = self.swatch
|
|
313
|
+
col_w = [0.0] * nc
|
|
314
|
+
nrows = max(1, (len(keys) + nc - 1) // nc)
|
|
315
|
+
rows_h = [max(sh, fs * 1.25)] * nrows
|
|
316
|
+
for i, k in enumerate(keys):
|
|
317
|
+
tw, _ = measure_text(k.label, fs)
|
|
318
|
+
r, c = divmod(i, nc)
|
|
319
|
+
col_w[c] = max(col_w[c], sw + self.gap + tw)
|
|
320
|
+
n_lines = str(k.label).count("\n") + 1
|
|
321
|
+
rows_h[r] = max(rows_h[r], fs * 1.25 * n_lines)
|
|
322
|
+
title_h = measure_text(self.title, fs, weight="bold")[1] + self.row_gap if self.title else 0.0
|
|
323
|
+
return col_w, rows_h, title_h
|
|
324
|
+
|
|
325
|
+
def _measure(self) -> Tuple[float, float]:
|
|
326
|
+
keys = self.keys()
|
|
327
|
+
col_w, rows_h, title_h = self._cells_rows()
|
|
328
|
+
nc = self._ncols()
|
|
329
|
+
rows = len(rows_h)
|
|
330
|
+
w = sum(col_w) + self.item_gap * (nc - 1)
|
|
331
|
+
if self.title:
|
|
332
|
+
w = max(w, measure_text(self.title, self._fs(), weight="bold")[0])
|
|
333
|
+
h = title_h + sum(rows_h) + (rows - 1) * self.row_gap
|
|
334
|
+
framed = self.fill != "none" or self.stroke not in (None, "none")
|
|
335
|
+
p = self.box_pad if framed else 0.0
|
|
336
|
+
return w + 2 * p, h + 2 * p
|
|
337
|
+
|
|
338
|
+
def draw(self, buf: List[str], ox: float = 0.0, oy: float = 0.0) -> None:
|
|
339
|
+
from .render_svg import draw_rect, draw_text
|
|
340
|
+
|
|
341
|
+
g = self.geom
|
|
342
|
+
x0, y0 = g.x + ox, g.y + oy
|
|
343
|
+
framed = self.fill != "none" or self.stroke not in (None, "none")
|
|
344
|
+
if framed:
|
|
345
|
+
draw_rect(buf, x0, y0, g.w, g.h, fill=self.fill, stroke=self.stroke or "none",
|
|
346
|
+
stroke_width=self.stroke_width if self.stroke not in (None, "none") else 0,
|
|
347
|
+
radius=self.theme.L(2), dasharray=self._dash(self.stroke_width))
|
|
348
|
+
x0 += self.box_pad
|
|
349
|
+
y0 += self.box_pad
|
|
350
|
+
fs = self._fs()
|
|
351
|
+
col_w, rows_h, title_h = self._cells_rows()
|
|
352
|
+
nc = self._ncols()
|
|
353
|
+
if self.title:
|
|
354
|
+
draw_text(buf, self.title, x0, y0 + fs, font_size=fs, weight="bold")
|
|
355
|
+
sw, sh = self.swatch
|
|
356
|
+
for i, k in enumerate(self.keys()):
|
|
357
|
+
r, c = divmod(i, nc)
|
|
358
|
+
x = x0 + sum(col_w[:c]) + self.item_gap * c
|
|
359
|
+
y = y0 + title_h + sum(rows_h[:r]) + r * self.row_gap
|
|
360
|
+
cy = y + rows_h[r] / 2
|
|
361
|
+
n_lines = str(k.label).count("\n") + 1
|
|
362
|
+
draw_key(buf, k, x, cy - sh / 2, sw, sh, theme=self.theme)
|
|
363
|
+
draw_text(buf, k.label, x + sw + self.gap,
|
|
364
|
+
cy + fs * 0.35 - (n_lines - 1) * fs * 1.25 / 2, font_size=fs)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def draw_key(buf: List[str], k: Key, x: float, y: float, w: float, h: float, *, theme: Any) -> None:
|
|
368
|
+
"""Swatch for one key in box (x, y, w, h)."""
|
|
369
|
+
from .primitives import _paint_node_pattern
|
|
370
|
+
from .render_svg import angle_of, draw_circle, draw_head, draw_line, draw_mark, draw_rect
|
|
371
|
+
from .styles import border_dasharray, resolve_edge_style
|
|
372
|
+
|
|
373
|
+
if k.kind == "box":
|
|
374
|
+
stroke = k.stroke
|
|
375
|
+
if stroke is None:
|
|
376
|
+
stroke = "#555555" if k.border not in (None, "none") else theme.stroke
|
|
377
|
+
sw = theme.SW(1.0)
|
|
378
|
+
rad = theme.L(k.radius)
|
|
379
|
+
draw_rect(buf, x, y, w, h, fill=k.fill or "#ffffff", stroke=stroke, stroke_width=sw,
|
|
380
|
+
radius=rad, dasharray=border_dasharray(k.border, sw) if k.border else None)
|
|
381
|
+
if k.pattern is not None:
|
|
382
|
+
_paint_node_pattern(buf, k.pattern, x + 0.5, y + 0.5, w - 1, h - 1)
|
|
383
|
+
draw_rect(buf, x, y, w, h, fill="none", stroke=stroke, stroke_width=sw, radius=rad,
|
|
384
|
+
dasharray=border_dasharray(k.border, sw) if k.border else None)
|
|
385
|
+
elif k.kind == "dot":
|
|
386
|
+
r = min(w, h) / 2
|
|
387
|
+
draw_circle(buf, x + w / 2, y + h / 2, r * 0.8, fill=k.fill or k.color or theme.stroke)
|
|
388
|
+
elif k.kind == "icon":
|
|
389
|
+
s = min(w, h + theme.L(4))
|
|
390
|
+
k.icon.paint(buf, x + (w - s) / 2, y + (h - s) / 2, s, s, color=k.color)
|
|
391
|
+
elif k.kind == "mark":
|
|
392
|
+
draw_mark(buf, k.mark or "check", x + w / 2, y + h / 2, min(w, h), color=k.color or theme.stroke)
|
|
393
|
+
else:
|
|
394
|
+
st = resolve_edge_style(theme, color=k.color, width=k.width, style=k.style,
|
|
395
|
+
head=(k.head if k.kind == "arrow" else "none"), tail=k.tail,
|
|
396
|
+
hollow=k.hollow)
|
|
397
|
+
cy = y + h / 2
|
|
398
|
+
eh = st.end_head
|
|
399
|
+
sh = st.start_head
|
|
400
|
+
x1, x2 = x, x + w
|
|
401
|
+
sb_e = eh.setback(st.width) if eh is not None and eh.visible else 0.0
|
|
402
|
+
sb_s = sh.setback(st.width) if sh is not None and sh.visible else 0.0
|
|
403
|
+
draw_line(buf, x1 + sb_s, cy, x2 - sb_e, cy, stroke=st.color, stroke_width=st.width,
|
|
404
|
+
dasharray=st.dasharray(), opacity=st.opacity)
|
|
405
|
+
draw_head(buf, x2, cy, angle_of(x1, cy, x2, cy), eh, color=st.color, opacity=st.opacity,
|
|
406
|
+
stroke_width=st.width)
|
|
407
|
+
draw_head(buf, x1, cy, angle_of(x2, cy, x1, cy), sh, color=st.color, opacity=st.opacity,
|
|
408
|
+
stroke_width=st.width)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
__all__ = ["Hatch", "Key", "Legend", "collect_keys", "draw_key"]
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def hatch_pattern(spec: Optional[str]) -> Optional["Hatch"]:
|
|
415
|
+
"""Matplotlib-style hatch spec → pattern: ``"////"`` (45°), ``"\\\\"`` (−45°),
|
|
416
|
+
``"xxxx"`` (cross), ``"----"`` (horizontal), ``"||||"`` (vertical), ``"...."`` (dots)."""
|
|
417
|
+
if not spec:
|
|
418
|
+
return None
|
|
419
|
+
c = spec[0]
|
|
420
|
+
if c == "/":
|
|
421
|
+
return Hatch(angle=45, color="#444444")
|
|
422
|
+
if c == "\\":
|
|
423
|
+
return Hatch(angle=-45, color="#444444")
|
|
424
|
+
if c in "xX+":
|
|
425
|
+
return Hatch(angle=45, cross=True, color="#444444")
|
|
426
|
+
if c == "-":
|
|
427
|
+
return Hatch(angle=0, color="#444444")
|
|
428
|
+
if c == "|":
|
|
429
|
+
return Hatch(angle=90, color="#444444")
|
|
430
|
+
if c in ".o":
|
|
431
|
+
from .primitives import DotGrid
|
|
432
|
+
from .styles import current_theme
|
|
433
|
+
|
|
434
|
+
L = current_theme().L
|
|
435
|
+
return DotGrid(pitch=L(6.0), r=L(0.9), color="#444444", opacity=0.8)
|
|
436
|
+
raise ValueError(f"unknown hatch spec {spec!r}")
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def position_corners(panel: Any) -> None:
|
|
440
|
+
"""Place corner legends after routing, retaining collision scores for lint."""
|
|
441
|
+
from .constraints import walk
|
|
442
|
+
from .edges import Diagram
|
|
443
|
+
from .figure import LayoutError
|
|
444
|
+
import warnings
|
|
445
|
+
|
|
446
|
+
legends = panel.__dict__.get('_corner_legends', [])
|
|
447
|
+
if not legends:
|
|
448
|
+
return
|
|
449
|
+
nodes = list(walk(panel.content))
|
|
450
|
+
obstacles = []
|
|
451
|
+
for n in nodes:
|
|
452
|
+
if not n.children():
|
|
453
|
+
g = n.geom
|
|
454
|
+
obstacles.append((g.x, g.y, g.w, g.h))
|
|
455
|
+
if isinstance(n, Diagram):
|
|
456
|
+
for _, points in n._routed()[0]:
|
|
457
|
+
for a, b in zip(points, points[1:]):
|
|
458
|
+
obstacles.append((min(a[0], b[0]) - .5, min(a[1], b[1]) - .5,
|
|
459
|
+
abs(a[0]-b[0]) + 1, abs(a[1]-b[1]) + 1))
|
|
460
|
+
for legend in legends:
|
|
461
|
+
g = (legend.of or panel.content).geom
|
|
462
|
+
w, h = legend.measure()
|
|
463
|
+
inset = legend.inset
|
|
464
|
+
if w + 2*inset > g.w or h + 2*inset > g.h:
|
|
465
|
+
raise LayoutError('Legend exceeds reference area; shorten labels, shrink the legend or use external placement')
|
|
466
|
+
candidates = {}
|
|
467
|
+
for corner in CORNERS:
|
|
468
|
+
x = g.x + inset if corner.endswith('left') else g.x + g.w - inset - w
|
|
469
|
+
y = g.y + inset if corner.startswith('top') else g.y + g.h - inset - h
|
|
470
|
+
score = sum(max(0, min(x+w, a+c)-max(x,a))*max(0,min(y+h,b+d)-max(y,b))
|
|
471
|
+
for a,b,c,d in obstacles)
|
|
472
|
+
candidates[corner] = (x,y,score)
|
|
473
|
+
choice = legend.corner
|
|
474
|
+
if choice == 'auto':
|
|
475
|
+
choice = min(CORNERS, key=lambda c: round(candidates[c][2], 8))
|
|
476
|
+
if candidates[choice][2] > 0:
|
|
477
|
+
from .overlaps import OverlapWarning
|
|
478
|
+
warnings.warn('Legend: all corners overlap content; using minimum overlap', OverlapWarning, stacklevel=2)
|
|
479
|
+
x,y,score = candidates[choice]
|
|
480
|
+
legend.layout(x,y,w,h)
|
|
481
|
+
legend._corner_conflict = score
|
|
482
|
+
legend._clear_corners = [c for c in CORNERS if candidates[c][2] == 0 and c != choice]
|
|
483
|
+
obstacles.append((x,y,w,h))
|