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/fills.py ADDED
@@ -0,0 +1,146 @@
1
+ """Paint objects usable anywhere a ``fill=`` colour is accepted (v0.4.0).
2
+
3
+ ``Gradient("#dbe8fb", "#2e6fd0", angle=90)`` — linear gradient (angle: 0 = left→right,
4
+ 90 = top→bottom; any number of colour stops). ``Split("#cfe", "#fdd")`` — two or more
5
+ hard-edged colour regions (``split="diagonal" | "antidiagonal" | "vertical" |
6
+ "horizontal"``, ``at=`` the boundary fraction), e.g. a half-frozen / half-trained module.
7
+
8
+ Both are resolved by the SVG drawing helpers into ``<linearGradient>`` defs in
9
+ objectBoundingBox units, so they work on rectangles, rounded boxes, ellipses and any
10
+ outline path (shapes), on Group / Box / Key swatches alike.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import math
16
+ from typing import List, Optional, Sequence, Tuple, Union
17
+
18
+ _seq = [0]
19
+
20
+
21
+ def _next_id(prefix: str) -> str:
22
+ _seq[0] += 1
23
+ return f"cf-{prefix}-{_seq[0]}"
24
+
25
+
26
+ class Paint:
27
+ """Base for non-solid fills. ``ref(buf)`` emits defs and returns ``url(#id)``."""
28
+
29
+ def stops(self) -> List[Tuple[float, str]]: # pragma: no cover - abstract
30
+ raise NotImplementedError
31
+
32
+ def vector(self) -> Tuple[float, float, float, float]: # pragma: no cover
33
+ raise NotImplementedError
34
+
35
+ def ref(self, buf: List[str]) -> str:
36
+ gid = _next_id(type(self).__name__.lower())
37
+ x1, y1, x2, y2 = self.vector()
38
+ st = "".join(f'<stop offset="{o:.4f}" stop-color="{c}"{self._op()} />'
39
+ for o, c in self.stops())
40
+ buf.append(f'<defs><linearGradient id="{gid}" x1="{x1:.4f}" y1="{y1:.4f}" '
41
+ f'x2="{x2:.4f}" y2="{y2:.4f}">{st}</linearGradient></defs>')
42
+ return f"url(#{gid})"
43
+
44
+ def _op(self) -> str:
45
+ op = getattr(self, "opacity", 1.0)
46
+ return f' stop-opacity="{op:.3f}"' if op < 0.999 else ""
47
+
48
+ def primary(self) -> str:
49
+ """A representative solid colour (first stop) for places that need one."""
50
+ return self.stops()[0][1]
51
+
52
+ def __str__(self) -> str: # legend signatures / debugging
53
+ return f"{type(self).__name__}({', '.join(c for _, c in self.stops())})"
54
+
55
+
56
+ def _angle_vector(angle: float) -> Tuple[float, float, float, float]:
57
+ t = math.radians(angle)
58
+ dx, dy = math.cos(t), math.sin(t)
59
+ # unit-square vector through the centre whose ends project onto the far corners
60
+ k = 0.5 * (abs(dx) + abs(dy))
61
+ return 0.5 - dx * k, 0.5 - dy * k, 0.5 + dx * k, 0.5 + dy * k
62
+
63
+
64
+ class Gradient(Paint):
65
+ """Linear gradient fill: ``Gradient(c1, c2, ..., angle=0)`` (0 = left→right)."""
66
+
67
+ def __init__(self, *colors: Union[str, Sequence[str]], angle: float = 0.0,
68
+ opacity: float = 1.0) -> None:
69
+ if len(colors) == 1 and not isinstance(colors[0], str):
70
+ colors = tuple(colors[0]) # type: ignore[assignment]
71
+ if len(colors) < 2:
72
+ raise ValueError("Gradient needs at least two colours")
73
+ self.colors = [str(c) for c in colors]
74
+ self.angle = float(angle)
75
+ self.opacity = float(opacity)
76
+
77
+ def stops(self) -> List[Tuple[float, str]]:
78
+ n = len(self.colors) - 1
79
+ return [(i / n, c) for i, c in enumerate(self.colors)]
80
+
81
+ def vector(self) -> Tuple[float, float, float, float]:
82
+ return _angle_vector(self.angle)
83
+
84
+
85
+ _SPLITS = {
86
+ "vertical": (0.0, 0.5, 1.0, 0.5), # boundary is a vertical line: left | right
87
+ "horizontal": (0.5, 0.0, 0.5, 1.0), # top / bottom
88
+ "diagonal": (0.0, 0.0, 1.0, 1.0), # top-left ◤ / ◢ bottom-right
89
+ "antidiagonal": (1.0, 0.0, 0.0, 1.0), # top-right ◥ / ◣ bottom-left
90
+ }
91
+
92
+
93
+ class Split(Paint):
94
+ """Hard-edged multi-colour fill: ``Split(c1, c2, split="diagonal", at=0.5)``.
95
+
96
+ ``split``: vertical (left | right halves) · horizontal (top / bottom) · diagonal
97
+ (corner to corner, ◤◢) · antidiagonal (◥◣). ``at``: boundary position (two colours)
98
+ or a list of boundaries for more colours (default: equal bands).
99
+ """
100
+
101
+ def __init__(self, *colors: Union[str, Sequence[str]], split: str = "diagonal",
102
+ at: Optional[Union[float, Sequence[float]]] = None, opacity: float = 1.0) -> None:
103
+ if len(colors) == 1 and not isinstance(colors[0], str):
104
+ colors = tuple(colors[0]) # type: ignore[assignment]
105
+ if len(colors) < 2:
106
+ raise ValueError("Split needs at least two colours")
107
+ if split not in _SPLITS:
108
+ raise ValueError(f"split must be one of {tuple(_SPLITS)}, got {split!r}")
109
+ self.colors = [str(c) for c in colors]
110
+ self.split = split
111
+ n = len(self.colors)
112
+ if at is None:
113
+ cuts = [i / n for i in range(1, n)]
114
+ elif isinstance(at, (int, float)):
115
+ if n != 2:
116
+ raise ValueError("a single at= works with two colours; pass a list")
117
+ cuts = [float(at)]
118
+ else:
119
+ cuts = [float(a) for a in at]
120
+ if len(cuts) != n - 1:
121
+ raise ValueError("at= needs len(colors) - 1 boundaries")
122
+ self.cuts = cuts
123
+ self.opacity = float(opacity)
124
+
125
+ def stops(self) -> List[Tuple[float, str]]:
126
+ out: List[Tuple[float, str]] = []
127
+ bounds = [0.0] + self.cuts + [1.0]
128
+ for i, c in enumerate(self.colors):
129
+ out.append((bounds[i], c))
130
+ out.append((bounds[i + 1], c))
131
+ return out
132
+
133
+ def vector(self) -> Tuple[float, float, float, float]:
134
+ return _SPLITS[self.split]
135
+
136
+
137
+ def resolve_paint(buf: List[str], fill) -> str:
138
+ """Return an SVG paint string for ``fill`` (emitting gradient defs when needed)."""
139
+ if fill is None:
140
+ return "none"
141
+ if isinstance(fill, Paint):
142
+ return fill.ref(buf)
143
+ return str(fill)
144
+
145
+
146
+ __all__ = ["Gradient", "Paint", "Split", "resolve_paint"]
codefig/flows.py ADDED
@@ -0,0 +1,102 @@
1
+ """Wrapped flows (v0.7.0): a long chain of steps folded into rows.
2
+
3
+ ``WrapFlow(tokens, per_row=5)`` lays the items out row by row and connects consecutive
4
+ items with arrows. ``mode="snake"`` (default) reverses every other row so the chain turns
5
+ down at the row end (boustrophedon — no long return arrows); ``mode="z"`` keeps every row
6
+ left → right and routes the row-to-row connector back under the row. Items may be
7
+ nodes or strings (→ :class:`~codefig.primitives.Box`). ``items[i]`` stays addressable
8
+ for extra edges / highlights; ``flow.edge(i)`` is the arrow leaving item ``i``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any, Dict, List, Optional, Sequence, Union
14
+
15
+ from .edges import Diagram, Edge
16
+ from .node import Grid, Node
17
+ from .primitives import Box, Spacer
18
+ from .styles import themed
19
+
20
+
21
+ _ROUTER_PAD = 5.0 # obstacle padding used by Diagram routing (edges.collect_obstacles)
22
+
23
+
24
+ class WrapFlow(Diagram):
25
+ """Chain of items folded into rows of ``per_row`` (see module docs).
26
+
27
+ per_row: items per row; ``max_w``: alternatively, fold so a row is at most this wide
28
+ (items measured at construction). gap: default spacing; ``col_gap`` (between items in
29
+ a row) and ``row_gap`` (between rows) override it per axis (v0.8.0). The default
30
+ ``row_gap`` in ``mode="z"`` leaves a clear lane for the return connectors. arrows: connect consecutive items (``edge=`` dict of Edge keywords,
31
+ e.g. ``edge={"color": "#0072B2"}``). ``equal=True`` gives every column the same width
32
+ so the snake's turns stay vertical.
33
+ """
34
+
35
+ flow_gap = themed(16.0)
36
+
37
+ def __init__(self, items: Sequence[Union[Node, str]], *, per_row: Optional[int] = None,
38
+ max_w: Optional[float] = None, mode: str = "snake", gap: Optional[float] = None,
39
+ row_gap: Optional[float] = None, col_gap: Optional[float] = None,
40
+ arrows: bool = True,
41
+ edge: Optional[Dict[str, Any]] = None, equal: bool = True,
42
+ align: str = "center", **kwargs: Any) -> None:
43
+ if mode not in ("snake", "z"):
44
+ raise ValueError("mode must be snake|z")
45
+ nodes: List[Node] = [Box(t) if isinstance(t, str) else t for t in items]
46
+ if not nodes:
47
+ raise ValueError("WrapFlow needs at least one item")
48
+ self.flow_gap = gap
49
+ g = float(self.flow_gap)
50
+ cgap = g if col_gap is None else float(col_gap)
51
+ if per_row is None:
52
+ if max_w is None:
53
+ per_row = len(nodes)
54
+ else:
55
+ widths = [n.measure()[0] for n in nodes]
56
+ wmax = max(widths)
57
+ per_row = max(1, int((float(max_w) + cgap) // (wmax + cgap)))
58
+ per_row = max(1, int(per_row))
59
+ self.items = nodes
60
+ self.per_row = per_row
61
+ self.mode = mode
62
+ cells: List[Node] = []
63
+ rows = (len(nodes) + per_row - 1) // per_row
64
+ self._rc: List[tuple] = []
65
+ for r in range(rows):
66
+ chunk = nodes[r * per_row:(r + 1) * per_row]
67
+ rev = mode == "snake" and r % 2 == 1
68
+ row: List[Node] = list(reversed(chunk)) if rev else list(chunk)
69
+ pad = per_row - len(chunk)
70
+ if pad:
71
+ fill = [Spacer() for _ in range(pad)]
72
+ row = fill + row if rev else row + fill
73
+ cells += row
74
+ for i in range(len(nodes)):
75
+ r, c = divmod(i, per_row)
76
+ rev = mode == "snake" and r % 2 == 1
77
+ self._rc.append((r, per_row - 1 - c if rev else c, rev))
78
+ if row_gap is None:
79
+ # z-mode return legs run along the row gap: leave a lane clear of the
80
+ # router's obstacle padding (5 pt each side) plus a stub's worth of air.
81
+ row_gap = max(g, 2 * _ROUTER_PAD + self.theme.L(6.0)) if mode == "z" else g
82
+ grid = Grid(cells, cols=per_row, gap=g, row_gap=float(row_gap), col_gap=cgap,
83
+ align=align, equal_cols=equal)
84
+ edges: List[Edge] = []
85
+ kw = dict(edge or {})
86
+ if arrows:
87
+ for i in range(len(nodes) - 1):
88
+ a, b = nodes[i], nodes[i + 1]
89
+ ra, _ca, reva = self._rc[i]
90
+ rb = self._rc[i + 1][0]
91
+ if ra == rb:
92
+ if reva:
93
+ edges.append(Edge(a.port("left"), b.port("right"), **kw))
94
+ else:
95
+ edges.append(Edge(a.port("right"), b.port("left"), **kw))
96
+ else:
97
+ edges.append(Edge(a.port("bottom"), b.port("top"), **kw))
98
+ super().__init__(grid, edges, **kwargs)
99
+
100
+ def edge(self, i: int) -> Edge:
101
+ """The connector leaving item ``i``."""
102
+ return self.edges[i]
codefig/glyphs.py ADDED
@@ -0,0 +1,395 @@
1
+ """Small generic glyph primitives (v0.2.0): Badge, Divider, BarChart, VectorFan, Swatch.
2
+
3
+ All are ordinary Nodes: they measure themselves, take part in flex layout and
4
+ constraints, expose ports, and are visible to overlap checks / edge routing.
5
+ None takes absolute coordinates.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ from typing import Any, Callable, List, Optional, Sequence, Tuple, Union
12
+
13
+ from .node import Node
14
+ from .styles import theme_default
15
+ from .textutil import measure_text
16
+
17
+ _DASH = {"solid": None, "dashed": "5 3", "dotted": "1.5 2.5"}
18
+
19
+
20
+ class Badge(Node):
21
+ """Text inside a pill, circle or rectangle sized to the text.
22
+
23
+ shape="pill" → tag/label pill (e.g. PREFILL); "circle" → circled number or
24
+ operator (①, ⊕ combiner); "rect" → plain chip.
25
+ """
26
+
27
+ stroke = theme_default("stroke")
28
+
29
+ def __init__(
30
+ self,
31
+ text: str = "",
32
+ *,
33
+ shape: str = "pill",
34
+ fill: str = "none",
35
+ stroke: Optional[str] = None,
36
+ stroke_width: float = 1.0,
37
+ text_color: str = "#111111",
38
+ font_size: Optional[float] = None,
39
+ weight: str = "normal",
40
+ pad: Optional[float] = None,
41
+ size: Optional[float] = None, # circle: fixed diameter
42
+ **kwargs: Any,
43
+ ) -> None:
44
+ super().__init__(**kwargs)
45
+ if shape not in ("pill", "circle", "rect"):
46
+ raise ValueError(f"Badge shape must be pill|circle|rect, got {shape!r}")
47
+ self.text = text
48
+ self.shape = shape
49
+ self.fill = fill
50
+ self.stroke = stroke
51
+ self.stroke_width = float(stroke_width)
52
+ self.text_color = text_color
53
+ self.font_size = font_size
54
+ self.weight = weight
55
+ self.pad = pad
56
+ self.size = size
57
+
58
+ def _fs(self) -> float:
59
+ return self.font_size if self.font_size is not None else self.theme.font_size
60
+
61
+ def _measure(self) -> Tuple[float, float]:
62
+ fs = self._fs()
63
+ tw, th = measure_text(self.text, fs, weight=self.weight) if self.text else (0.0, fs)
64
+ th = min(th, fs * 1.25 * max(1, self.text.count("\n") + 1))
65
+ if self.shape == "circle":
66
+ pad = fs * 0.12 if self.pad is None else self.pad
67
+ d = self.size if self.size is not None else max(tw, fs) + 2 * pad
68
+ return d, d
69
+ pad = fs * 0.35 if self.pad is None else self.pad
70
+ return tw + 2 * pad + (fs * 0.2 if self.shape == "pill" else 0), th + 2 * pad * 0.35
71
+
72
+ def draw(self, buf: List[str], ox: float = 0.0, oy: float = 0.0) -> None:
73
+ from .render_svg import draw_circle, draw_rect, draw_text
74
+
75
+ g = self.geom
76
+ x, y, w, h = g.x + ox, g.y + oy, g.w, g.h
77
+ sw = self.stroke_width if self.stroke not in (None, "none") else 0.0
78
+ if self.shape == "circle":
79
+ r = min(w, h) / 2 - sw / 2
80
+ draw_circle(buf, x + w / 2, y + h / 2, r, fill=self.fill, stroke=self.stroke or "none",
81
+ stroke_width=sw, dasharray=self._dash(sw))
82
+ else:
83
+ rad = h / 2 if self.shape == "pill" else 2
84
+ draw_rect(buf, x + sw / 2, y + sw / 2, w - sw, h - sw, fill=self.fill,
85
+ stroke=self.stroke or "none", stroke_width=sw, radius=rad,
86
+ dasharray=self._dash(sw))
87
+ if self.text:
88
+ fs = self._fs()
89
+ tw, _ = measure_text(self.text, fs, weight=self.weight)
90
+ draw_text(buf, self.text, x + (w - tw) / 2, y + h / 2 + fs * 0.35, font_size=fs,
91
+ fill=self.text_color, weight=self.weight)
92
+
93
+
94
+ class Divider(Node):
95
+ """A separator line. In a Row/Col with align="stretch" it spans the cross axis.
96
+
97
+ orientation: vertical (in a Row) | horizontal (in a Col).
98
+ """
99
+
100
+ def __init__(
101
+ self,
102
+ orientation: str = "vertical",
103
+ *,
104
+ color: str = "#b0b0b0",
105
+ width: float = 1.0,
106
+ style: str = "dashed",
107
+ margin: float = 2.0,
108
+ **kwargs: Any,
109
+ ) -> None:
110
+ kwargs.setdefault("align_self", "stretch")
111
+ super().__init__(**kwargs)
112
+ if orientation not in ("vertical", "horizontal"):
113
+ raise ValueError("orientation must be vertical|horizontal")
114
+ self.orientation = orientation
115
+ self.color = color
116
+ self.width = float(width)
117
+ self.style = style
118
+ self.margin = float(margin)
119
+
120
+ def _measure(self) -> Tuple[float, float]:
121
+ t = self.width + 2 * self.margin
122
+ return (t, 1.0) if self.orientation == "vertical" else (1.0, t)
123
+
124
+ def draw(self, buf: List[str], ox: float = 0.0, oy: float = 0.0) -> None:
125
+ from .render_svg import draw_line
126
+
127
+ g = self.geom
128
+ if self.orientation == "vertical":
129
+ x = g.x + ox + g.w / 2
130
+ draw_line(buf, x, g.y + oy, x, g.y + oy + g.h, stroke=self.color,
131
+ stroke_width=self.width, dasharray=_DASH.get(self.style, self.style))
132
+ else:
133
+ y = g.y + oy + g.h / 2
134
+ draw_line(buf, g.x + ox, y, g.x + ox + g.w, y, stroke=self.color,
135
+ stroke_width=self.width, dasharray=_DASH.get(self.style, self.style))
136
+
137
+
138
+ class BarChart(Node):
139
+ """Inline bar chart / sparkline: bars, highlighted bars, optional marker dots.
140
+
141
+ values: bar heights (any unit; scaled so the tallest item = ``height``).
142
+ highlight: {index: colour} or a set of indices (→ ``highlight_color``).
143
+ markers: {index: value} — a dot drawn at that value above bar ``index``.
144
+ baseline: draw an axis line under the bars.
145
+ """
146
+
147
+ def __init__(
148
+ self,
149
+ values: Sequence[float],
150
+ *,
151
+ height: float = 60.0,
152
+ bar_w: float = 6.0,
153
+ bar_gap: float = 1.5,
154
+ color: str = "#c4c4c4",
155
+ highlight: Optional[Union[dict, Sequence[int]]] = None,
156
+ highlight_color: str = "#2e6fd0",
157
+ markers: Optional[dict] = None,
158
+ marker_color: str = "#7d3c98",
159
+ marker_size: float = 5.0,
160
+ baseline: bool = True,
161
+ baseline_color: str = "#888888",
162
+ **kwargs: Any,
163
+ ) -> None:
164
+ super().__init__(**kwargs)
165
+ if not values:
166
+ raise ValueError("BarChart needs at least one value")
167
+ self.values = [float(v) for v in values]
168
+ self.height = float(height)
169
+ self.bar_w = float(bar_w)
170
+ self.bar_gap = float(bar_gap)
171
+ self.color = color
172
+ if highlight is None:
173
+ self.highlight: dict = {}
174
+ elif isinstance(highlight, dict):
175
+ self.highlight = dict(highlight)
176
+ else:
177
+ self.highlight = {int(i): highlight_color for i in highlight}
178
+ self.markers = dict(markers or {})
179
+ self.marker_color = marker_color
180
+ self.marker_size = float(marker_size)
181
+ self.baseline = baseline
182
+ self.baseline_color = baseline_color
183
+
184
+ def _top(self) -> float:
185
+ return max(list(self.values) + [float(v) for v in self.markers.values()] + [1e-9])
186
+
187
+ def _measure(self) -> Tuple[float, float]:
188
+ n = len(self.values)
189
+ w = n * self.bar_w + (n - 1) * self.bar_gap
190
+ return w, self.height + (self.marker_size / 2 + 1 if self.markers else 0) + 1.5
191
+
192
+ def _bar_x(self, i: int) -> float:
193
+ return self.geom.x + i * (self.bar_w + self.bar_gap)
194
+
195
+ def _bar_cx(self, i: int) -> float:
196
+ return self._bar_x(i) + self.bar_w / 2
197
+
198
+ def bar_at(self, x: float) -> int:
199
+ """Index of the bar whose centre is nearest to page coordinate ``x``."""
200
+ return min(range(len(self.values)), key=lambda i: abs(self._bar_cx(i) - x))
201
+
202
+ def bar_anchor(self, i: int, which: str = "center"):
203
+ """x-Anchor on bar ``i`` (start | center | end) — e.g. ``Brace(span=(…))``."""
204
+ from .constraints import Anchor
205
+
206
+ if not -len(self.values) <= i < len(self.values):
207
+ raise IndexError(f"bar {i} out of range")
208
+ i = i % len(self.values)
209
+ off = {"start": 0.0, "left": 0.0, "center": 0.5, "end": 1.0, "right": 1.0}[which]
210
+ return Anchor(self, "x", getter=lambda: self._bar_x(i) + self.bar_w * off,
211
+ label=f"bar[{i}].{which}")
212
+
213
+ def bars(self, i: int, j: int):
214
+ """(start-of-bar-i, end-of-bar-j) anchors — a span for ``Brace(span=…)``."""
215
+ return self.bar_anchor(i, "start"), self.bar_anchor(j, "end")
216
+
217
+ def bar_port(self, i: int, side: str = "top"):
218
+ """Port on bar ``i``: top → the bar's tip, bottom → under the baseline."""
219
+ from .edges import Port
220
+
221
+ if not 0 <= i < len(self.values):
222
+ raise IndexError(f"bar {i} out of range")
223
+ return Port(self, "cell", side, index=0, col_index=i)
224
+
225
+ def resolve_cell_port(self, _row: int, i: int, side: str):
226
+ g = self.geom
227
+ x = self._bar_x(i) + self.bar_w / 2
228
+ if side == "bottom":
229
+ return x, g.y + g.h, "down"
230
+ base = g.y + g.h - 1.5
231
+ return x, base - self.values[i] / self._top() * self.height, "up"
232
+
233
+ def draw(self, buf: List[str], ox: float = 0.0, oy: float = 0.0) -> None:
234
+ from .render_svg import draw_circle, draw_line, draw_rect
235
+
236
+ g = self.geom
237
+ base = g.y + oy + g.h - 1.5
238
+ top = self._top()
239
+ for i, v in enumerate(self.values):
240
+ h = v / top * self.height
241
+ x = self._bar_x(i) + ox
242
+ draw_rect(buf, x, base - h, self.bar_w, h, fill=self.highlight.get(i, self.color),
243
+ stroke="none", stroke_width=0)
244
+ for i, v in self.markers.items():
245
+ if 0 <= int(i) < len(self.values):
246
+ cx = self._bar_x(int(i)) + ox + self.bar_w / 2
247
+ cy = base - float(v) / top * self.height
248
+ draw_circle(buf, cx, cy, self.marker_size / 2, fill=self.marker_color)
249
+ if self.baseline:
250
+ draw_line(buf, g.x + ox, base + 0.5, g.x + ox + g.w, base + 0.5,
251
+ stroke=self.baseline_color, stroke_width=self.theme.SW(1.0))
252
+
253
+
254
+ class VectorFan(Node):
255
+ """Arrows radiating from one origin, with optional faint axes — the usual
256
+ "vectors point in many / one direction(s)" sketch.
257
+
258
+ angles: directions in degrees (0 = right, counter-clockwise).
259
+ lengths: optional per-arrow fractions of ``radius`` (default 1).
260
+ """
261
+
262
+ def __init__(
263
+ self,
264
+ angles: Sequence[float],
265
+ *,
266
+ lengths: Optional[Sequence[float]] = None,
267
+ radius: float = 22.0,
268
+ color: str = "#1f3a68",
269
+ colors: Optional[Sequence[str]] = None,
270
+ width: float = 1.1,
271
+ head_size: float = 4.5,
272
+ axes: bool = True,
273
+ axes_color: str = "#c8c8c8",
274
+ **kwargs: Any,
275
+ ) -> None:
276
+ super().__init__(**kwargs)
277
+ self.angles = [float(a) for a in angles]
278
+ if lengths is not None and len(lengths) != len(self.angles):
279
+ raise ValueError("lengths must match angles")
280
+ self.lengths = [float(x) for x in lengths] if lengths is not None else [1.0] * len(self.angles)
281
+ self.radius = float(radius)
282
+ self.color = color
283
+ self.colors = list(colors) if colors is not None else None
284
+ self.width = float(width)
285
+ self.head_size = float(head_size)
286
+ self.axes = axes
287
+ self.axes_color = axes_color
288
+
289
+ def _measure(self) -> Tuple[float, float]:
290
+ d = 2 * (self.radius + self.head_size * 0.5 + 2)
291
+ return d, d
292
+
293
+ def draw(self, buf: List[str], ox: float = 0.0, oy: float = 0.0) -> None:
294
+ from .render_svg import draw_arrowhead, draw_line
295
+
296
+ g = self.geom
297
+ cx, cy = g.x + ox + g.w / 2, g.y + oy + g.h / 2
298
+ r = self.radius
299
+ if self.axes:
300
+ draw_line(buf, cx - r * 1.1, cy, cx + r * 1.1, cy, stroke=self.axes_color, stroke_width=self.theme.SW(0.8))
301
+ draw_line(buf, cx, cy - r * 1.1, cx, cy + r * 1.1, stroke=self.axes_color, stroke_width=self.theme.SW(0.8))
302
+ for k, (a, f) in enumerate(zip(self.angles, self.lengths)):
303
+ col = self.colors[k % len(self.colors)] if self.colors else self.color
304
+ t = math.radians(a)
305
+ ex, ey = cx + math.cos(t) * r * f, cy - math.sin(t) * r * f
306
+ draw_line(buf, cx, cy, ex, ey, stroke=col, stroke_width=self.width)
307
+ draw_arrowhead(buf, ex, ey, math.atan2(ey - cy, ex - cx), size=self.head_size, color=col)
308
+
309
+
310
+ class Swatch(Node):
311
+ """Legend key: kind = square | rect | dot | line | arrow.
312
+
313
+ line / arrow honour ``style`` (solid | dashed | dotted | custom dash) and arrows
314
+ take ``head`` / ``tail`` / ``hollow`` like Edge; square / rect take ``fill``-colour
315
+ ``color``, ``stroke``, ``border`` and ``pattern`` (e.g. ``Hatch()``).
316
+ """
317
+
318
+ def __init__(
319
+ self,
320
+ kind: str = "square",
321
+ *,
322
+ color: str = "#2e6fd0",
323
+ size: float = 9.0,
324
+ length: float = 22.0,
325
+ width: float = 1.5,
326
+ style: Any = "solid",
327
+ head: Any = None,
328
+ tail: Any = None,
329
+ hollow: Optional[bool] = None,
330
+ head_size: Optional[float] = None,
331
+ stroke: Optional[str] = None,
332
+ pattern: Any = None,
333
+ **kwargs: Any,
334
+ ) -> None:
335
+ super().__init__(**kwargs)
336
+ if kind not in ("square", "rect", "dot", "line", "arrow"):
337
+ raise ValueError("Swatch kind must be square|rect|dot|line|arrow")
338
+ self.kind = kind
339
+ self.color = color
340
+ self.size = float(size)
341
+ self.length = float(length)
342
+ self.width = float(width)
343
+ self.style = style
344
+ self.head = head
345
+ self.tail = tail
346
+ self.hollow = hollow
347
+ self.head_size = head_size
348
+ self.stroke = stroke
349
+ self.pattern = pattern
350
+
351
+ def _measure(self) -> Tuple[float, float]:
352
+ if self.kind in ("line", "arrow"):
353
+ return self.length, max(self.size, 6.0)
354
+ if self.kind == "rect":
355
+ return self.length, self.size
356
+ return self.size, self.size
357
+
358
+ def draw(self, buf: List[str], ox: float = 0.0, oy: float = 0.0) -> None:
359
+ from .legend import Key, draw_key
360
+
361
+ g = self.geom
362
+ x, y, w, h = g.x + ox, g.y + oy, g.w, g.h
363
+ if self.kind in ("square", "rect"):
364
+ s = min(w, h)
365
+ bw = s if self.kind == "square" else w
366
+ stroke = self.stroke if self.stroke is not None else (
367
+ "#555555" if self.border not in (None, "none") else "none")
368
+ k = Key("", kind="box", fill=self.color, stroke=stroke, border=self.border,
369
+ pattern=self.pattern, radius=0.0)
370
+ if stroke == "none":
371
+ from .render_svg import draw_rect
372
+
373
+ draw_rect(buf, x + (w - bw) / 2, y + (h - s) / 2, bw, s, fill=self.color,
374
+ stroke="none", stroke_width=0)
375
+ if self.pattern is not None:
376
+ from .primitives import _paint_node_pattern
377
+
378
+ _paint_node_pattern(buf, self.pattern, x + (w - bw) / 2, y + (h - s) / 2, bw, s)
379
+ else:
380
+ draw_key(buf, k, x + (w - bw) / 2, y + (h - s) / 2, bw, s, theme=self.theme)
381
+ elif self.kind == "dot":
382
+ from .render_svg import draw_circle
383
+
384
+ draw_circle(buf, x + w / 2, y + h / 2, min(w, h) / 2, fill=self.color)
385
+ else:
386
+ head = self.head if self.head is not None else ("triangle" if self.kind == "arrow" else "none")
387
+ from .styles import Head
388
+
389
+ if self.head_size is not None and isinstance(head, str):
390
+ head = Head(head, size=self.head_size)
391
+ elif self.head_size is None and isinstance(head, str) and head != "none":
392
+ head = Head(head, size=6.0)
393
+ k = Key("", kind="arrow", color=self.color, style=self.style, width=self.width,
394
+ head=head, tail=self.tail, hollow=self.hollow)
395
+ draw_key(buf, k, x, y, w, h, theme=self.theme)