figkit 0.1.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.
- figkit/__init__.py +114 -0
- figkit/audit.py +611 -0
- figkit/colors.py +304 -0
- figkit/component.py +110 -0
- figkit/components.py +727 -0
- figkit/connectors.py +689 -0
- figkit/core.py +1021 -0
- figkit/export.py +301 -0
- figkit/figure.py +312 -0
- figkit/fonts.py +462 -0
- figkit/frame.py +376 -0
- figkit/geom.py +497 -0
- figkit/image.py +308 -0
- figkit/layout.py +529 -0
- figkit/mathtext.py +320 -0
- figkit/paint.py +136 -0
- figkit/py.typed +0 -0
- figkit/shapes.py +764 -0
- figkit/style.py +502 -0
- figkit/svgdoc.py +133 -0
- figkit/svgpath.py +470 -0
- figkit/text.py +625 -0
- figkit/themes.py +163 -0
- figkit-0.1.0.dist-info/METADATA +277 -0
- figkit-0.1.0.dist-info/RECORD +28 -0
- figkit-0.1.0.dist-info/WHEEL +5 -0
- figkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- figkit-0.1.0.dist-info/top_level.txt +1 -0
figkit/connectors.py
ADDED
|
@@ -0,0 +1,689 @@
|
|
|
1
|
+
"""Arrows and connectors: straight, elbow, curved, or via waypoints.
|
|
2
|
+
|
|
3
|
+
Endpoints are stored as *live references* — anchors, elements or raw points —
|
|
4
|
+
and resolved at render time, so moving a box drags its arrows along.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
|
|
11
|
+
from .core import Anchor, Element
|
|
12
|
+
from .geom import BBox, Point, polyline_length, to_point
|
|
13
|
+
from .paint import paint_attrs
|
|
14
|
+
from .svgdoc import Node, RenderContext
|
|
15
|
+
from .svgpath import (fmt, flatten_path, path_bbox, path_from_points,
|
|
16
|
+
point_at, rounded_polyline)
|
|
17
|
+
from .text import Text
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Connector", "arrow", "line", "elbow", "curve", "connect", "double_arrow",
|
|
21
|
+
"self_loop", "PathAnchor", "HEADS",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
HEADS = ("triangle", "stealth", "open", "vee", "circle", "dot", "diamond",
|
|
25
|
+
"square", "bar", "tee", "cross", "none")
|
|
26
|
+
|
|
27
|
+
_SIDE_NORMAL = {"n": Point(0, -1), "s": Point(0, 1),
|
|
28
|
+
"e": Point(1, 0), "w": Point(-1, 0)}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class PathAnchor(Anchor):
|
|
32
|
+
"""A live anchor at a fraction ``t`` along a connector's path.
|
|
33
|
+
|
|
34
|
+
Unlike a bare point it re-resolves on read, so anything attached to the
|
|
35
|
+
middle of an arrow follows the arrow when its endpoints move.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
__slots__ = ("t",)
|
|
39
|
+
|
|
40
|
+
def __init__(self, connector, t: float = 0.5, dx: float = 0.0,
|
|
41
|
+
dy: float = 0.0):
|
|
42
|
+
super().__init__(connector, name=None, dx=dx, dy=dy)
|
|
43
|
+
self.t = float(t)
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def point(self) -> Point:
|
|
47
|
+
p = self.element.point_at(self.t)
|
|
48
|
+
return Point(p.x + self.dx, p.y + self.dy)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def normal(self) -> Point:
|
|
52
|
+
d = self.element.direction_at(self.t)
|
|
53
|
+
return Point(-d.y, d.x)
|
|
54
|
+
|
|
55
|
+
def offset(self, dx: float = 0.0, dy: float = 0.0) -> "PathAnchor":
|
|
56
|
+
return PathAnchor(self.element, self.t, self.dx + dx, self.dy + dy)
|
|
57
|
+
|
|
58
|
+
def __repr__(self) -> str:
|
|
59
|
+
return f"<PathAnchor t={self.t:g} -> {self.point}>"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ==========================================================================
|
|
63
|
+
# Arrow heads
|
|
64
|
+
# ==========================================================================
|
|
65
|
+
|
|
66
|
+
def _head_geometry(kind: str, tip: Point, direction: Point, size: float,
|
|
67
|
+
width_ratio: float = 0.62) -> tuple:
|
|
68
|
+
"""Return ``(path_or_None, node_kwargs, inset)`` for one arrow head.
|
|
69
|
+
|
|
70
|
+
``inset`` is how far back along the line the stroke should stop so it does
|
|
71
|
+
not poke out of the head.
|
|
72
|
+
"""
|
|
73
|
+
kind = (kind or "none").lower()
|
|
74
|
+
if kind in ("none", "", "off"):
|
|
75
|
+
return None, {}, 0.0
|
|
76
|
+
d = direction.normalized()
|
|
77
|
+
if d.length == 0:
|
|
78
|
+
d = Point(1, 0)
|
|
79
|
+
n = Point(-d.y, d.x) # perpendicular
|
|
80
|
+
half = size * width_ratio
|
|
81
|
+
|
|
82
|
+
if kind in ("triangle", "arrow", "filled"):
|
|
83
|
+
back = tip - d * size
|
|
84
|
+
pts = [tip, back + n * half, back - n * half]
|
|
85
|
+
return path_from_points(pts, close=True), {"filled": True}, size * 0.92
|
|
86
|
+
if kind == "stealth":
|
|
87
|
+
back = tip - d * size
|
|
88
|
+
notch = tip - d * (size * 0.55)
|
|
89
|
+
pts = [tip, back + n * half, notch, back - n * half]
|
|
90
|
+
return path_from_points(pts, close=True), {"filled": True}, size * 0.6
|
|
91
|
+
if kind in ("open", "vee", "v", "line"):
|
|
92
|
+
back = tip - d * size
|
|
93
|
+
d_path = (f"M{fmt((back + n * half).x)} {fmt((back + n * half).y)}"
|
|
94
|
+
f"L{fmt(tip.x)} {fmt(tip.y)}"
|
|
95
|
+
f"L{fmt((back - n * half).x)} {fmt((back - n * half).y)}")
|
|
96
|
+
return d_path, {"filled": False}, size * 0.35
|
|
97
|
+
if kind in ("circle", "dot", "disc"):
|
|
98
|
+
r = size * 0.42
|
|
99
|
+
c = tip - d * r
|
|
100
|
+
return None, {"circle": (c, r), "filled": kind != "circle_open"}, r * 1.9
|
|
101
|
+
if kind == "diamond":
|
|
102
|
+
c = tip - d * (size / 2.0)
|
|
103
|
+
pts = [tip, c + n * half, tip - d * size, c - n * half]
|
|
104
|
+
return path_from_points(pts, close=True), {"filled": True}, size * 0.95
|
|
105
|
+
if kind in ("square", "box"):
|
|
106
|
+
c = tip - d * (size * 0.4)
|
|
107
|
+
h = size * 0.4
|
|
108
|
+
pts = [c + d * h + n * h, c + d * h - n * h,
|
|
109
|
+
c - d * h - n * h, c - d * h + n * h]
|
|
110
|
+
return path_from_points(pts, close=True), {"filled": True}, size * 0.78
|
|
111
|
+
if kind in ("bar", "tee", "stop"):
|
|
112
|
+
d_path = (f"M{fmt((tip + n * half).x)} {fmt((tip + n * half).y)}"
|
|
113
|
+
f"L{fmt((tip - n * half).x)} {fmt((tip - n * half).y)}")
|
|
114
|
+
return d_path, {"filled": False}, 0.0
|
|
115
|
+
if kind == "cross":
|
|
116
|
+
h = size * 0.45
|
|
117
|
+
d_path = (f"M{fmt((tip + n * h + d * h).x)} {fmt((tip + n * h + d * h).y)}"
|
|
118
|
+
f"L{fmt((tip - n * h - d * h).x)} {fmt((tip - n * h - d * h).y)}"
|
|
119
|
+
f"M{fmt((tip - n * h + d * h).x)} {fmt((tip - n * h + d * h).y)}"
|
|
120
|
+
f"L{fmt((tip + n * h - d * h).x)} {fmt((tip + n * h - d * h).y)}")
|
|
121
|
+
return d_path, {"filled": False}, 0.0
|
|
122
|
+
raise ValueError(f"unknown arrow head {kind!r}; use one of {HEADS}")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ==========================================================================
|
|
126
|
+
# Connector
|
|
127
|
+
# ==========================================================================
|
|
128
|
+
|
|
129
|
+
class Connector(Element):
|
|
130
|
+
"""A line between two live endpoints, with optional heads and a label.
|
|
131
|
+
|
|
132
|
+
Prefer the helpers :func:`arrow`, :func:`elbow`, :func:`curve` and
|
|
133
|
+
:func:`line` — they are the same class with different defaults.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
role = "arrow"
|
|
137
|
+
STROKE_WIDTH_ALIAS = True
|
|
138
|
+
|
|
139
|
+
def __init__(self, start, end, *, route: str = "straight", waypoints=None,
|
|
140
|
+
stub: float = 14.0, bend: float = 0.0, bow: float = 0.0,
|
|
141
|
+
corner: float = 6.0,
|
|
142
|
+
gap: float = 0.0, start_gap: float = None, end_gap: float = None,
|
|
143
|
+
head=None, tail=None, head_size=None, tail_size=None,
|
|
144
|
+
start_side: str = None, end_side: str = None,
|
|
145
|
+
label=None, label_pos: float = 0.5, label_offset: float = 9.0,
|
|
146
|
+
label_side: str = "auto", label_style=None, label_bg=None,
|
|
147
|
+
label_rotate: bool = False, **kw):
|
|
148
|
+
self.start_ref = start
|
|
149
|
+
self.end_ref = end
|
|
150
|
+
self.route = str(route).lower()
|
|
151
|
+
self.waypoints = list(waypoints or [])
|
|
152
|
+
self.stub = float(stub)
|
|
153
|
+
self.bend = float(bend)
|
|
154
|
+
self.bow = float(bow)
|
|
155
|
+
self.corner = float(corner)
|
|
156
|
+
self.start_gap = float(gap if start_gap is None else start_gap)
|
|
157
|
+
self.end_gap = float(gap if end_gap is None else end_gap)
|
|
158
|
+
self.start_side = start_side
|
|
159
|
+
self.end_side = end_side
|
|
160
|
+
self._head = head
|
|
161
|
+
self._tail = tail
|
|
162
|
+
self._head_size = head_size
|
|
163
|
+
self._tail_size = tail_size
|
|
164
|
+
self.label_pos = float(label_pos)
|
|
165
|
+
self.label_offset = float(label_offset)
|
|
166
|
+
self.label_side = label_side
|
|
167
|
+
self.label_bg = label_bg
|
|
168
|
+
self.label_rotate = label_rotate
|
|
169
|
+
self._label: Text | None = None
|
|
170
|
+
super().__init__(0, 0, None, None, **kw)
|
|
171
|
+
if label is not None and label != "":
|
|
172
|
+
self._label = Text(label, add=False, style=label_style)
|
|
173
|
+
self._label.parent = self
|
|
174
|
+
self._label.role = "label"
|
|
175
|
+
|
|
176
|
+
# -- endpoint resolution ---------------------------------------------
|
|
177
|
+
def _endpoint(self, ref, toward: Point, side: str = None) -> tuple:
|
|
178
|
+
"""Resolve one endpoint to ``(point, outward_normal)``."""
|
|
179
|
+
if isinstance(ref, Anchor):
|
|
180
|
+
p = ref.point
|
|
181
|
+
n = ref.normal
|
|
182
|
+
if n.length == 0:
|
|
183
|
+
n = (p - toward).normalized()
|
|
184
|
+
return p, n
|
|
185
|
+
if isinstance(ref, Element):
|
|
186
|
+
bb = ref.bbox
|
|
187
|
+
if side:
|
|
188
|
+
key = str(side).lower()[:2]
|
|
189
|
+
key = {"to": "n", "bo": "s", "le": "w", "ri": "e"}.get(key, key[0])
|
|
190
|
+
p = bb.anchor(key)
|
|
191
|
+
return p, _SIDE_NORMAL.get(key, Point(0, 0))
|
|
192
|
+
angle = math.degrees(math.atan2(toward.y - bb.cy, toward.x - bb.cx))
|
|
193
|
+
p = bb.at_angle(angle)
|
|
194
|
+
n = _dominant_normal(p, bb)
|
|
195
|
+
return p, n
|
|
196
|
+
p = to_point(ref)
|
|
197
|
+
return p, Point(0, 0)
|
|
198
|
+
|
|
199
|
+
def endpoints(self) -> tuple:
|
|
200
|
+
"""``((p0, n0), (p1, n1))`` resolved right now."""
|
|
201
|
+
hint_a = to_point(self.waypoints[0]) if self.waypoints else _rough(self.end_ref)
|
|
202
|
+
hint_b = to_point(self.waypoints[-1]) if self.waypoints else _rough(self.start_ref)
|
|
203
|
+
a = self._endpoint(self.start_ref, hint_a, self.start_side)
|
|
204
|
+
b = self._endpoint(self.end_ref, hint_b, self.end_side)
|
|
205
|
+
if not self.waypoints:
|
|
206
|
+
a = self._endpoint(self.start_ref, b[0], self.start_side)
|
|
207
|
+
b = self._endpoint(self.end_ref, a[0], self.end_side)
|
|
208
|
+
return a, b
|
|
209
|
+
|
|
210
|
+
# -- geometry --------------------------------------------------------
|
|
211
|
+
def geometry(self) -> tuple:
|
|
212
|
+
"""``(path_data, start_point, start_dir, end_point, end_dir)``."""
|
|
213
|
+
(p0, n0), (p1, n1) = self.endpoints()
|
|
214
|
+
if self.start_gap:
|
|
215
|
+
p0 = p0 + (n0 if n0.length else (p1 - p0).normalized()) * self.start_gap
|
|
216
|
+
if self.end_gap:
|
|
217
|
+
p1 = p1 + (n1 if n1.length else (p0 - p1).normalized()) * self.end_gap
|
|
218
|
+
wp = [to_point(w) for w in self.waypoints]
|
|
219
|
+
route = self.route
|
|
220
|
+
|
|
221
|
+
if route in ("elbow", "orth", "orthogonal", "hv", "vh", "manhattan"):
|
|
222
|
+
pts = _elbow_points(p0, n0, p1, n1, self.stub, route, wp)
|
|
223
|
+
d = rounded_polyline(pts, self.corner) if self.corner else \
|
|
224
|
+
path_from_points(pts)
|
|
225
|
+
sd = (pts[1] - pts[0]) if len(pts) > 1 else Point(1, 0)
|
|
226
|
+
ed = (pts[-1] - pts[-2]) if len(pts) > 1 else Point(1, 0)
|
|
227
|
+
return d, p0, sd, p1, ed
|
|
228
|
+
|
|
229
|
+
if route in ("curve", "bezier", "spline", "arc"):
|
|
230
|
+
d, sd, ed = _curve_path(p0, n0, p1, n1, wp, self.bend,
|
|
231
|
+
self.bow, arc=(route == "arc"))
|
|
232
|
+
return d, p0, sd, p1, ed
|
|
233
|
+
|
|
234
|
+
pts = [p0] + wp + [p1]
|
|
235
|
+
if self.corner and wp:
|
|
236
|
+
d = rounded_polyline(pts, self.corner)
|
|
237
|
+
else:
|
|
238
|
+
d = path_from_points(pts)
|
|
239
|
+
sd = (pts[1] - pts[0]) if len(pts) > 1 else Point(1, 0)
|
|
240
|
+
ed = (pts[-1] - pts[-2]) if len(pts) > 1 else Point(1, 0)
|
|
241
|
+
return d, p0, sd, p1, ed
|
|
242
|
+
|
|
243
|
+
def path_data(self, bb: BBox = None) -> str:
|
|
244
|
+
return self.geometry()[0]
|
|
245
|
+
|
|
246
|
+
def polyline(self, steps: int = 24) -> list:
|
|
247
|
+
pts: list = []
|
|
248
|
+
for poly in flatten_path(self.path_data(), steps):
|
|
249
|
+
pts.extend(poly)
|
|
250
|
+
return [Point(*p) for p in pts]
|
|
251
|
+
|
|
252
|
+
def point_at(self, t: float) -> Point:
|
|
253
|
+
"""Point at fraction ``t`` (0..1) along the connector."""
|
|
254
|
+
return point_at(self.path_data(), t)[0]
|
|
255
|
+
|
|
256
|
+
def direction_at(self, t: float) -> Point:
|
|
257
|
+
return point_at(self.path_data(), t)[1]
|
|
258
|
+
|
|
259
|
+
def anchor_at(self, t: float) -> PathAnchor:
|
|
260
|
+
"""A *live* anchor at fraction ``t`` along the path."""
|
|
261
|
+
return PathAnchor(self, t)
|
|
262
|
+
|
|
263
|
+
@property
|
|
264
|
+
def mid(self) -> PathAnchor:
|
|
265
|
+
"""A live anchor at the midpoint of the path."""
|
|
266
|
+
return PathAnchor(self, 0.5)
|
|
267
|
+
|
|
268
|
+
@property
|
|
269
|
+
def length(self) -> float:
|
|
270
|
+
return polyline_length(self.polyline())
|
|
271
|
+
|
|
272
|
+
def _measure(self) -> None:
|
|
273
|
+
d = self.path_data()
|
|
274
|
+
x0, y0, x1, y1 = path_bbox(d) if d else (0, 0, 0, 0)
|
|
275
|
+
self._x, self._y = x0, y0
|
|
276
|
+
self._w, self._h = x1 - x0, y1 - y0
|
|
277
|
+
if self._label is not None:
|
|
278
|
+
self._place_label()
|
|
279
|
+
|
|
280
|
+
@property
|
|
281
|
+
def local_bbox(self) -> BBox:
|
|
282
|
+
self._dirty = True # endpoints are live; always re-measure
|
|
283
|
+
self._ensure()
|
|
284
|
+
bb = BBox(self._x, self._y, self._w or 0.0, self._h or 0.0)
|
|
285
|
+
if self._label is not None:
|
|
286
|
+
bb = bb.union(self._label.local_bbox)
|
|
287
|
+
return bb
|
|
288
|
+
|
|
289
|
+
# -- label -----------------------------------------------------------
|
|
290
|
+
@property
|
|
291
|
+
def label(self) -> Text | None:
|
|
292
|
+
self._ensure()
|
|
293
|
+
return self._label
|
|
294
|
+
|
|
295
|
+
def set_label(self, text, **style) -> "Connector":
|
|
296
|
+
if self._label is None:
|
|
297
|
+
self._label = Text(text, add=False)
|
|
298
|
+
self._label.parent = self
|
|
299
|
+
self._label.role = "label"
|
|
300
|
+
else:
|
|
301
|
+
self._label.text = text
|
|
302
|
+
if style:
|
|
303
|
+
self._label.restyle(**style)
|
|
304
|
+
return self.invalidate()
|
|
305
|
+
|
|
306
|
+
def _place_label(self) -> None:
|
|
307
|
+
self._label.reset_transform() # placement re-runs on every measure
|
|
308
|
+
p, d = point_at(self.path_data(), self.label_pos)
|
|
309
|
+
n = Point(-d.y, d.x)
|
|
310
|
+
side = str(self.label_side).lower()
|
|
311
|
+
if side in ("auto", "above", "left"):
|
|
312
|
+
if n.y > 0:
|
|
313
|
+
n = -n
|
|
314
|
+
elif side in ("below", "right"):
|
|
315
|
+
if n.y < 0:
|
|
316
|
+
n = -n
|
|
317
|
+
elif side in ("none", "on", "center"):
|
|
318
|
+
n = Point(0, 0)
|
|
319
|
+
target = p + n * self.label_offset
|
|
320
|
+
anchor = "center"
|
|
321
|
+
if self.label_rotate:
|
|
322
|
+
anchor = "center" # a rotated label reads best centred
|
|
323
|
+
elif abs(n.x) > abs(n.y) and n.length:
|
|
324
|
+
anchor = "w" if n.x > 0 else "e"
|
|
325
|
+
elif n.length:
|
|
326
|
+
anchor = "n" if n.y > 0 else "s"
|
|
327
|
+
self._label.at(target.x, target.y, anchor=anchor)
|
|
328
|
+
if self.label_rotate:
|
|
329
|
+
angle = math.degrees(math.atan2(d.y, d.x))
|
|
330
|
+
if angle > 90 or angle < -90: # keep the text right way up
|
|
331
|
+
angle += 180
|
|
332
|
+
self._label.rotate(angle)
|
|
333
|
+
|
|
334
|
+
# -- rendering -------------------------------------------------------
|
|
335
|
+
def _render_content(self, ctx: RenderContext):
|
|
336
|
+
d, p0, sd, p1, ed = self.geometry()
|
|
337
|
+
if not d:
|
|
338
|
+
return None
|
|
339
|
+
stroke = self.prop("stroke", None)
|
|
340
|
+
stroke_w = self.prop("stroke_width", 1.5) or 1.5
|
|
341
|
+
head_kind = self._head if self._head is not None else self.prop("head")
|
|
342
|
+
tail_kind = self._tail if self._tail is not None else self.prop("tail")
|
|
343
|
+
head_size = float(self._head_size if self._head_size is not None
|
|
344
|
+
else self.prop("head_size", 9))
|
|
345
|
+
tail_size = float(self._tail_size if self._tail_size is not None
|
|
346
|
+
else self.prop("tail_size", head_size))
|
|
347
|
+
# heads scale a little with the line weight so they never look pinned on
|
|
348
|
+
head_size = head_size * (0.72 + 0.28 * max(1.0, float(stroke_w)))
|
|
349
|
+
tail_size = tail_size * (0.72 + 0.28 * max(1.0, float(stroke_w)))
|
|
350
|
+
|
|
351
|
+
nodes: list = []
|
|
352
|
+
head_nodes: list = []
|
|
353
|
+
has_head = bool(head_kind) and str(head_kind).lower() != "none"
|
|
354
|
+
has_tail = bool(tail_kind) and str(tail_kind).lower() != "none"
|
|
355
|
+
|
|
356
|
+
# How far back the stroke has to stop for each head to cover its end.
|
|
357
|
+
trim_end = _head_inset(head_kind, head_size) if has_head else 0.0
|
|
358
|
+
trim_start = _head_inset(tail_kind, tail_size) if has_tail else 0.0
|
|
359
|
+
if trim_start or trim_end:
|
|
360
|
+
d = _trim_path(d, trim_start, trim_end)
|
|
361
|
+
|
|
362
|
+
# Point each head from where the stroke now ends toward the tip, rather
|
|
363
|
+
# than along the tangent at the tip. On a curve those differ, and using
|
|
364
|
+
# the tangent lets the stroke escape sideways from under the head.
|
|
365
|
+
if has_head:
|
|
366
|
+
ed = _aim(d, p1, ed, at_start=False)
|
|
367
|
+
hd, meta, _ = _head_geometry(head_kind, p1, ed, head_size)
|
|
368
|
+
head_nodes.append(_head_node(hd, meta, stroke, stroke_w, self, ctx))
|
|
369
|
+
if has_tail:
|
|
370
|
+
sd = -_aim(d, p0, -sd, at_start=True)
|
|
371
|
+
td, meta, _ = _head_geometry(tail_kind, p0, -sd, tail_size)
|
|
372
|
+
head_nodes.append(_head_node(td, meta, stroke, stroke_w, self, ctx))
|
|
373
|
+
|
|
374
|
+
line_attrs = paint_attrs(self, ctx)
|
|
375
|
+
line_attrs["fill"] = "none"
|
|
376
|
+
nodes.append(Node("path", d=d, **line_attrs))
|
|
377
|
+
nodes.extend(n for n in head_nodes if n is not None)
|
|
378
|
+
|
|
379
|
+
if self._label is not None:
|
|
380
|
+
self._ensure()
|
|
381
|
+
if self.label_bg:
|
|
382
|
+
lb = self._label.local_bbox.expand((2, 4))
|
|
383
|
+
bg = self.label_bg if isinstance(self.label_bg, str) else "#ffffff"
|
|
384
|
+
nodes.append(Node("rect", x=lb.x, y=lb.y, width=lb.w,
|
|
385
|
+
height=lb.h, rx=3, fill=bg, stroke="none"))
|
|
386
|
+
n = self._label.render(ctx)
|
|
387
|
+
if n is not None:
|
|
388
|
+
nodes.append(n)
|
|
389
|
+
return nodes
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _head_inset(kind: str, size: float) -> float:
|
|
393
|
+
"""How far short of the tip the stroke must stop for this head shape."""
|
|
394
|
+
_d, _meta, inset = _head_geometry(kind, Point(0, 0), Point(1, 0), size)
|
|
395
|
+
return inset
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _aim(d: str, tip: Point, fallback: Point, at_start: bool) -> Point:
|
|
399
|
+
"""Direction from the (already trimmed) stroke end toward ``tip``."""
|
|
400
|
+
if not d:
|
|
401
|
+
return fallback
|
|
402
|
+
polys = flatten_path(d, 8)
|
|
403
|
+
if not polys or not polys[0]:
|
|
404
|
+
return fallback
|
|
405
|
+
end = Point(*(polys[0][0] if at_start else polys[-1][-1]))
|
|
406
|
+
direction = tip - end
|
|
407
|
+
return direction.normalized() if direction.length > 1e-6 else fallback
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _head_node(d, meta, stroke, stroke_w, el, ctx) -> Node | None:
|
|
411
|
+
color = stroke if stroke not in (None, "none") else el.prop("fill", "#000")
|
|
412
|
+
if "circle" in meta:
|
|
413
|
+
c, r = meta["circle"]
|
|
414
|
+
return Node("ellipse", cx=c.x, cy=c.y, rx=r, ry=r, fill=color,
|
|
415
|
+
stroke="none")
|
|
416
|
+
if d is None:
|
|
417
|
+
return None
|
|
418
|
+
if meta.get("filled"):
|
|
419
|
+
return Node("path", d=d, fill=color, stroke="none")
|
|
420
|
+
return Node("path", d=d, fill="none", stroke=color, stroke_width=stroke_w,
|
|
421
|
+
stroke_linecap="round", stroke_linejoin="round")
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _rough(ref) -> Point:
|
|
425
|
+
"""A cheap point for an endpoint, used only to orient the other end."""
|
|
426
|
+
if isinstance(ref, Anchor):
|
|
427
|
+
return ref.point
|
|
428
|
+
if isinstance(ref, Element):
|
|
429
|
+
return ref.bbox.center
|
|
430
|
+
return to_point(ref)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _dominant_normal(p: Point, bb: BBox) -> Point:
|
|
434
|
+
"""Which side of ``bb`` does point ``p`` sit on?"""
|
|
435
|
+
tol = 1e-6
|
|
436
|
+
if abs(p.x - bb.x0) < tol:
|
|
437
|
+
return Point(-1, 0)
|
|
438
|
+
if abs(p.x - bb.x1) < tol:
|
|
439
|
+
return Point(1, 0)
|
|
440
|
+
if abs(p.y - bb.y0) < tol:
|
|
441
|
+
return Point(0, -1)
|
|
442
|
+
if abs(p.y - bb.y1) < tol:
|
|
443
|
+
return Point(0, 1)
|
|
444
|
+
return Point(0, 0)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _elbow_points(p0: Point, n0: Point, p1: Point, n1: Point, stub: float,
|
|
448
|
+
mode: str, waypoints: list) -> list:
|
|
449
|
+
"""Orthogonal route from ``p0`` to ``p1`` honouring the exit normals."""
|
|
450
|
+
if waypoints:
|
|
451
|
+
pts = [p0]
|
|
452
|
+
prev = p0
|
|
453
|
+
for w in waypoints:
|
|
454
|
+
pts.extend(_ortho_pair(prev, w))
|
|
455
|
+
prev = w
|
|
456
|
+
pts.extend(_ortho_pair(prev, p1))
|
|
457
|
+
pts.append(p1)
|
|
458
|
+
return _dedupe(pts)
|
|
459
|
+
|
|
460
|
+
if n0.length == 0 and n1.length == 0:
|
|
461
|
+
n0, n1 = _infer_normals(p0, p1, mode)
|
|
462
|
+
elif n0.length == 0:
|
|
463
|
+
n0 = -n1 if abs(n1.x) > abs(n1.y) else -n1
|
|
464
|
+
elif n1.length == 0:
|
|
465
|
+
n1 = -n0
|
|
466
|
+
|
|
467
|
+
a = p0 + n0 * stub
|
|
468
|
+
b = p1 + n1 * stub
|
|
469
|
+
h0 = abs(n0.x) > abs(n0.y)
|
|
470
|
+
h1 = abs(n1.x) > abs(n1.y)
|
|
471
|
+
|
|
472
|
+
if h0 and h1:
|
|
473
|
+
mx = (a.x + b.x) / 2.0
|
|
474
|
+
pts = [p0, a, Point(mx, a.y), Point(mx, b.y), b, p1]
|
|
475
|
+
elif (not h0) and (not h1):
|
|
476
|
+
my = (a.y + b.y) / 2.0
|
|
477
|
+
pts = [p0, a, Point(a.x, my), Point(b.x, my), b, p1]
|
|
478
|
+
elif h0 and not h1:
|
|
479
|
+
pts = [p0, a, Point(b.x, a.y), b, p1]
|
|
480
|
+
else:
|
|
481
|
+
pts = [p0, a, Point(a.x, b.y), b, p1]
|
|
482
|
+
return _dedupe(pts)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _ortho_pair(a: Point, b: Point) -> list:
|
|
486
|
+
b = to_point(b)
|
|
487
|
+
if abs(b.x - a.x) < 1e-9 or abs(b.y - a.y) < 1e-9:
|
|
488
|
+
return []
|
|
489
|
+
return [Point(b.x, a.y)]
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _infer_normals(p0: Point, p1: Point, mode: str) -> tuple:
|
|
493
|
+
dx, dy = p1.x - p0.x, p1.y - p0.y
|
|
494
|
+
horizontal_first = abs(dx) >= abs(dy)
|
|
495
|
+
if mode == "hv":
|
|
496
|
+
horizontal_first = True
|
|
497
|
+
elif mode == "vh":
|
|
498
|
+
horizontal_first = False
|
|
499
|
+
if horizontal_first:
|
|
500
|
+
n0 = Point(1 if dx >= 0 else -1, 0)
|
|
501
|
+
n1 = Point(0, -1 if dy >= 0 else 1)
|
|
502
|
+
else:
|
|
503
|
+
n0 = Point(0, 1 if dy >= 0 else -1)
|
|
504
|
+
n1 = Point(-1 if dx >= 0 else 1, 0)
|
|
505
|
+
return n0, n1
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _dedupe(pts: list) -> list:
|
|
509
|
+
out = [pts[0]]
|
|
510
|
+
for p in pts[1:]:
|
|
511
|
+
if abs(p.x - out[-1].x) > 1e-7 or abs(p.y - out[-1].y) > 1e-7:
|
|
512
|
+
out.append(p)
|
|
513
|
+
return out
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _curve_path(p0: Point, n0: Point, p1: Point, n1: Point, waypoints: list,
|
|
517
|
+
bend: float, bow: float = 0.0, arc: bool = False) -> tuple:
|
|
518
|
+
"""Build the curve.
|
|
519
|
+
|
|
520
|
+
``bend`` deepens the bow: when the endpoints are anchors (so we know which
|
|
521
|
+
way they face) it lengthens the handles *along those normals*; for plain
|
|
522
|
+
points there is no normal to follow, so it bows sideways instead.
|
|
523
|
+
``bow`` always displaces sideways, positive = left of travel.
|
|
524
|
+
"""
|
|
525
|
+
chord = p1 - p0
|
|
526
|
+
dist = chord.length or 1.0
|
|
527
|
+
if waypoints:
|
|
528
|
+
pts = [p0] + waypoints + [p1]
|
|
529
|
+
return _catmull_rom(pts), (waypoints[0] - p0), (p1 - waypoints[-1])
|
|
530
|
+
|
|
531
|
+
left = Point(chord.y, -chord.x).normalized() # left of travel (y is down)
|
|
532
|
+
|
|
533
|
+
if arc or (n0.length == 0 and n1.length == 0):
|
|
534
|
+
b = bend + bow if (bend or bow) else 0.25
|
|
535
|
+
mid = p0.lerp(p1, 0.5) + left * (dist * b)
|
|
536
|
+
d = (f"M{fmt(p0.x)} {fmt(p0.y)}Q{fmt(mid.x)} {fmt(mid.y)} "
|
|
537
|
+
f"{fmt(p1.x)} {fmt(p1.y)}")
|
|
538
|
+
return d, (mid - p0), (p1 - mid)
|
|
539
|
+
|
|
540
|
+
# Handle reach follows how well each exit normal lines up with the chord:
|
|
541
|
+
# a sideways exit gets a short handle so the curve stays tidy.
|
|
542
|
+
u = chord.normalized()
|
|
543
|
+
d0 = n0 if n0.length else u
|
|
544
|
+
d1 = n1 if n1.length else -u
|
|
545
|
+
reach = max(0.18, abs(bend))
|
|
546
|
+
k0 = min(dist * 0.9, max(10.0, dist * 0.55 * max(abs(u.dot(d0)), reach)))
|
|
547
|
+
k1 = min(dist * 0.9, max(10.0, dist * 0.55 * max(abs((-u).dot(d1)), reach)))
|
|
548
|
+
c0 = p0 + d0 * k0
|
|
549
|
+
c1 = p1 + d1 * k1
|
|
550
|
+
if bow:
|
|
551
|
+
c0 = c0 + left * (dist * bow * 0.6)
|
|
552
|
+
c1 = c1 + left * (dist * bow * 0.6)
|
|
553
|
+
d = (f"M{fmt(p0.x)} {fmt(p0.y)}C{fmt(c0.x)} {fmt(c0.y)} "
|
|
554
|
+
f"{fmt(c1.x)} {fmt(c1.y)} {fmt(p1.x)} {fmt(p1.y)}")
|
|
555
|
+
return d, (c0 - p0), (p1 - c1)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _catmull_rom(points: list, tension: float = 0.5) -> str:
|
|
559
|
+
"""Smooth cubic spline through all the points."""
|
|
560
|
+
pts = [to_point(p) for p in points]
|
|
561
|
+
if len(pts) < 3:
|
|
562
|
+
return path_from_points(pts)
|
|
563
|
+
ext = [pts[0]] + pts + [pts[-1]]
|
|
564
|
+
parts = [f"M{fmt(pts[0].x)} {fmt(pts[0].y)}"]
|
|
565
|
+
for i in range(1, len(ext) - 2):
|
|
566
|
+
p0, p1, p2, p3 = ext[i - 1], ext[i], ext[i + 1], ext[i + 2]
|
|
567
|
+
c1 = p1 + (p2 - p0) * (tension / 3.0)
|
|
568
|
+
c2 = p2 - (p3 - p1) * (tension / 3.0)
|
|
569
|
+
parts.append(f"C{fmt(c1.x)} {fmt(c1.y)} {fmt(c2.x)} {fmt(c2.y)} "
|
|
570
|
+
f"{fmt(p2.x)} {fmt(p2.y)}")
|
|
571
|
+
return "".join(parts)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _trim_path(d: str, start: float, end: float) -> str:
|
|
575
|
+
"""Shorten a path from both ends so arrow heads sit flush."""
|
|
576
|
+
polys = flatten_path(d, 28)
|
|
577
|
+
if not polys:
|
|
578
|
+
return d
|
|
579
|
+
pts = [Point(*p) for p in polys[0]]
|
|
580
|
+
for poly in polys[1:]:
|
|
581
|
+
pts.extend(Point(*p) for p in poly)
|
|
582
|
+
if len(pts) < 2:
|
|
583
|
+
return d
|
|
584
|
+
total = polyline_length(pts)
|
|
585
|
+
if total <= start + end + 0.5:
|
|
586
|
+
return ""
|
|
587
|
+
if start > 0:
|
|
588
|
+
pts = _cut(pts, start, from_start=True)
|
|
589
|
+
if end > 0:
|
|
590
|
+
pts = _cut(pts, end, from_start=False)
|
|
591
|
+
return path_from_points(pts)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _cut(pts: list, amount: float, from_start: bool) -> list:
|
|
595
|
+
work = list(pts) if from_start else list(reversed(pts))
|
|
596
|
+
acc = 0.0
|
|
597
|
+
for i in range(len(work) - 1):
|
|
598
|
+
seg = work[i].distance_to(work[i + 1])
|
|
599
|
+
if acc + seg >= amount:
|
|
600
|
+
t = (amount - acc) / seg if seg else 0.0
|
|
601
|
+
new_pt = work[i].lerp(work[i + 1], t)
|
|
602
|
+
out = [new_pt] + work[i + 1:]
|
|
603
|
+
return out if from_start else list(reversed(out))
|
|
604
|
+
acc += seg
|
|
605
|
+
return pts
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
# ==========================================================================
|
|
609
|
+
# Friendly constructors
|
|
610
|
+
# ==========================================================================
|
|
611
|
+
|
|
612
|
+
def arrow(start, end, **kw) -> Connector:
|
|
613
|
+
"""A straight arrow from ``start`` to ``end``.
|
|
614
|
+
|
|
615
|
+
>>> arrow(fe.e, fm.w)
|
|
616
|
+
>>> arrow(box_a, box_b, head="stealth", label="loss")
|
|
617
|
+
"""
|
|
618
|
+
kw.setdefault("route", "straight")
|
|
619
|
+
return Connector(start, end, **kw)
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def line(start, end, **kw) -> Connector:
|
|
623
|
+
"""A plain line (no arrow head)."""
|
|
624
|
+
kw.setdefault("route", "straight")
|
|
625
|
+
kw.setdefault("head", "none")
|
|
626
|
+
return Connector(start, end, **kw)
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def elbow(start, end, stub: float = 14.0, **kw) -> Connector:
|
|
630
|
+
"""An orthogonal ``-|`` style connector with a straight ``stub`` at each end."""
|
|
631
|
+
kw.setdefault("route", "elbow")
|
|
632
|
+
return Connector(start, end, stub=stub, **kw)
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def curve(start, end, bend: float = 0.0, **kw) -> Connector:
|
|
636
|
+
"""A smooth curve between two endpoints.
|
|
637
|
+
|
|
638
|
+
``bend`` deepens the bow — following the anchors' facing direction when
|
|
639
|
+
you connect anchors (``a.s`` leaves downward), or sideways for plain
|
|
640
|
+
points. ``bow=`` always pushes sideways (positive = left of travel), and
|
|
641
|
+
``waypoints=[...]`` routes the curve through specific points.
|
|
642
|
+
"""
|
|
643
|
+
kw.setdefault("route", "curve")
|
|
644
|
+
return Connector(start, end, bend=bend, **kw)
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def connect(start, end, route: str = "straight", **kw) -> Connector:
|
|
648
|
+
"""Generic entry point: ``route`` is straight / elbow / curve / arc."""
|
|
649
|
+
return Connector(start, end, route=route, **kw)
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def double_arrow(start, end, **kw) -> Connector:
|
|
653
|
+
"""An arrow with heads on both ends."""
|
|
654
|
+
kw.setdefault("tail", kw.get("head", "triangle"))
|
|
655
|
+
return Connector(start, end, **kw)
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def self_loop(element, side: str = "top", size: float = 36.0,
|
|
659
|
+
spread: float = 0.45, **kw) -> Connector:
|
|
660
|
+
"""An arrow that leaves one element and comes back to it.
|
|
661
|
+
|
|
662
|
+
The staple of state machines and recurrent blocks. ``side`` picks the edge
|
|
663
|
+
it bulges from, ``size`` how far out it goes and ``spread`` how far apart
|
|
664
|
+
its feet sit, as a fraction of that edge.
|
|
665
|
+
|
|
666
|
+
>>> self_loop(state, side="top", label="retry")
|
|
667
|
+
"""
|
|
668
|
+
box = element.bbox
|
|
669
|
+
half = max(0.02, min(0.9, float(spread))) / 2.0
|
|
670
|
+
s = str(side).lower()
|
|
671
|
+
if s in ("top", "n", "up"):
|
|
672
|
+
start, end = box.uv(0.5 - half, 0.0), box.uv(0.5 + half, 0.0)
|
|
673
|
+
apex = Point(box.cx, box.y0 - size)
|
|
674
|
+
elif s in ("bottom", "s", "down"):
|
|
675
|
+
start, end = box.uv(0.5 + half, 1.0), box.uv(0.5 - half, 1.0)
|
|
676
|
+
apex = Point(box.cx, box.y1 + size)
|
|
677
|
+
elif s in ("left", "w"):
|
|
678
|
+
start, end = box.uv(0.0, 0.5 + half), box.uv(0.0, 0.5 - half)
|
|
679
|
+
apex = Point(box.x0 - size, box.cy)
|
|
680
|
+
elif s in ("right", "e"):
|
|
681
|
+
start, end = box.uv(1.0, 0.5 - half), box.uv(1.0, 0.5 + half)
|
|
682
|
+
apex = Point(box.x1 + size, box.cy)
|
|
683
|
+
else:
|
|
684
|
+
raise ValueError(f"side={side!r}; use top/bottom/left/right")
|
|
685
|
+
kw.setdefault("route", "curve")
|
|
686
|
+
# Put any label outside the loop rather than inside its arc.
|
|
687
|
+
kw.setdefault("label_side", {"bottom": "below", "s": "below",
|
|
688
|
+
"down": "below"}.get(s, "above"))
|
|
689
|
+
return Connector(start, end, waypoints=[apex], **kw)
|